diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index dcf47d5..868d5a1 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -162,3 +162,21 @@ - Как проверялось: `npm run check` чисто; `npx vitest run` 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user). - Влияние на поиск/UX: нет (форматы ответов не тронуты). - Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей). + +--- + +## [0.13] Биллинг: PaymentProvider + MockPaymentProvider + +- Статус: ✅ done +- Зачем: самообслуживание оплаты без superadmin; реальный эквайринг (ЮKassa/CloudPayments) позже без переписывания (решение пользователя — только mock + интерфейс). +- Что изменено: + - `server/billing/payment-provider.ts` — интерфейс PaymentProvider + фабрика по env `PAYMENT_PROVIDER` (дефолт mock). + - `server/billing/mock-provider.ts` — mock: externalId mock_, webhook-подпись sha256 (timingSafeEqual), secret env `MOCK_PAYMENT_SECRET`. + - `server/billing/payments.service.ts` — createBillingPayment (ON CONFLICT по idempotency_key), `applySucceededPayment`: транзакция с атомарным guard pending→succeeded (дубли webhook невозможны), credit в billing_transactions, инкремент balance, авто-снятие billingBlocked при балансе > 0 (та же логика, что superadmin unblock). + - `server/routes/billing-payments.routes.ts` — POST/GET /api/billing/payments (billing.manage), POST /:id/confirm-test (только mock, иначе 404), публичный POST /api/billing/webhooks/:provider (401 невалидная подпись, идемпотентность). + - `migrations/0080_billing_payments.sql` + таблица в shared/schema.ts (UNIQUE external_id/idempotency_key). + - `client/src/pages/Billing.tsx` — фикс setLocation в теле рендера (useEffect), кнопка «Пополнить» с диалогом, секция «Платежи», «Подтвердить (тест)» у pending при mock (признак paymentProvider в /api/billing/summary), текст блокировки про авто-восстановление. + - `tests/billing-payments.test.ts` — 13 тестов. +- Как проверялось: vitest 78/78 (дедуп по Idempotency-Key, повторный webhook не зачисляет дважды, изоляция организаций, 404 confirm-test при не-mock, 401 по подписи); `npm run check` чисто. +- Влияние на поиск/UX: нет. +- Подводные камни: на проде PAYMENT_PROVIDER не задан → mock активен, кнопка «Подтвердить (тест)» видна админам организаций — это и есть поставка шага; при подключении реального провайдера кнопка скроется автоматически. Подключение реального провайдера: новый класс в server/billing/ + case в фабрике + env (см. отчёт в коде payment-provider.ts). diff --git a/client/src/pages/Billing.tsx b/client/src/pages/Billing.tsx index 8c5ea1c..a75a9ed 100644 --- a/client/src/pages/Billing.tsx +++ b/client/src/pages/Billing.tsx @@ -1,13 +1,25 @@ -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/useAuth"; import { useLocation } from "wouter"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Wallet, Users, TrendingDown, Calendar, ArrowUpCircle, ArrowDownCircle, AlertTriangle } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Wallet, Users, TrendingDown, Calendar, ArrowUpCircle, ArrowDownCircle, AlertTriangle, PlusCircle } from "lucide-react"; import { format } from "date-fns"; import { ru } from "date-fns/locale"; import { apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; interface BillingSummary { balance: string; @@ -17,6 +29,7 @@ interface BillingSummary { billingBlocked: boolean; activeUsers: number; estimatedCharge: string; + paymentProvider: string; } interface BillingTransaction { @@ -29,37 +42,136 @@ interface BillingTransaction { createdBy: number | null; } +interface BillingPayment { + id: number; + organizationId: number; + provider: string; + externalId: string; + amount: string; + currency: string; + status: "pending" | "succeeded" | "canceled"; + createdAt: string; +} + +const PAYMENT_STATUS_LABELS: Record = { + pending: "Ожидает оплаты", + succeeded: "Оплачен", + canceled: "Отменён", +}; + +const PAYMENT_STATUS_CLASSES: Record = { + pending: "text-amber-600 border-amber-300", + succeeded: "text-emerald-600 border-emerald-300", + canceled: "text-muted-foreground border-border", +}; + export default function Billing() { const { user } = useAuth(); const [, setLocation] = useLocation(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const isAdmin = user?.appRole === "admin"; - if (user?.appRole !== "admin") { - setLocation("/"); - return null; - } + const [topUpOpen, setTopUpOpen] = useState(false); + const [amount, setAmount] = useState(""); + + useEffect(() => { + if (user && user.appRole !== "admin") { + setLocation("/"); + } + }, [user, setLocation]); const { data: summaryData, isLoading: loadingSummary } = useQuery<{ success: boolean; summary: BillingSummary }>({ queryKey: ["/api/billing/summary"], queryFn: () => apiRequest("GET", "/api/billing/summary").then(r => r.json()), + enabled: isAdmin, }); const { data: txData, isLoading: loadingTx } = useQuery<{ success: boolean; transactions: BillingTransaction[] }>({ queryKey: ["/api/billing/transactions"], queryFn: () => apiRequest("GET", "/api/billing/transactions").then(r => r.json()), + enabled: isAdmin, }); + const { data: paymentsData, isLoading: loadingPayments } = useQuery<{ success: boolean; payments: BillingPayment[] }>({ + queryKey: ["/api/billing/payments"], + queryFn: () => apiRequest("GET", "/api/billing/payments").then(r => r.json()), + enabled: isAdmin, + }); + + const invalidateBilling = () => { + queryClient.invalidateQueries({ queryKey: ["/api/billing/summary"] }); + queryClient.invalidateQueries({ queryKey: ["/api/billing/transactions"] }); + queryClient.invalidateQueries({ queryKey: ["/api/billing/payments"] }); + }; + + const createPaymentMutation = useMutation({ + mutationFn: (amountNum: number) => + apiRequest("POST", "/api/billing/payments", { amount: amountNum }).then(async r => { + const body = await r.json(); + if (!r.ok) throw new Error(body?.error || "Не удалось создать платёж"); + return body; + }), + onSuccess: () => { + setTopUpOpen(false); + setAmount(""); + invalidateBilling(); + toast({ title: "Платёж создан", description: "Платёж появился в списке со статусом «Ожидает оплаты»." }); + }, + onError: (error: Error) => { + toast({ title: "Ошибка", description: error.message, variant: "destructive" }); + }, + }); + + const confirmTestMutation = useMutation({ + mutationFn: (paymentId: number) => + apiRequest("POST", `/api/billing/payments/${paymentId}/confirm-test`).then(async r => { + const body = await r.json(); + if (!r.ok) throw new Error(body?.error || "Не удалось подтвердить платёж"); + return body; + }), + onSuccess: () => { + invalidateBilling(); + toast({ title: "Платёж подтверждён", description: "Баланс пополнен." }); + }, + onError: (error: Error) => { + toast({ title: "Ошибка", description: error.message, variant: "destructive" }); + }, + }); + + if (!isAdmin) { + return null; + } + const summary = summaryData?.summary; const transactions = txData?.transactions ?? []; + const payments = paymentsData?.payments ?? []; const currency = summary?.currency ?? "RUB"; + const isMockProvider = summary?.paymentProvider === "mock"; const formatAmount = (amount: string) => parseFloat(amount).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const handleTopUp = () => { + const amountNum = parseFloat(amount.replace(",", ".").replace(/\s/g, "")); + if (!amountNum || isNaN(amountNum) || amountNum <= 0) { + toast({ title: "Ошибка", description: "Введите положительную сумму", variant: "destructive" }); + return; + } + createPaymentMutation.mutate(amountNum); + }; + return (
-
-

Подписка и биллинг

-

Управление балансом и история операций

+
+
+

Подписка и биллинг

+

Управление балансом и история операций

+
+
{summary?.billingBlocked && ( @@ -68,7 +180,7 @@ export default function Billing() {

Доступ приостановлен

- Баланс организации исчерпан. Обратитесь к администратору системы для пополнения баланса. + Баланс организации исчерпан. Пополните баланс — доступ восстановится автоматически.

@@ -158,6 +270,51 @@ export default function Billing() {
+ {/* Payments */} + + + Платежи + + + {loadingPayments ? ( +
Загрузка...
+ ) : payments.length === 0 ? ( +
Платежей пока нет
+ ) : ( +
+ {payments.map((payment) => ( +
+
+

+ {formatAmount(payment.amount)} {payment.currency} + · {payment.provider} +

+

+ {payment.createdAt ? format(new Date(payment.createdAt), "d MMM yyyy, HH:mm", { locale: ru }) : ""} +

+
+
+ + {PAYMENT_STATUS_LABELS[payment.status]} + + {payment.status === "pending" && isMockProvider && ( + + )} +
+
+ ))} +
+ )} +
+
+ {/* Transaction history */} @@ -195,9 +352,39 @@ export default function Billing() { -

- Для пополнения баланса обратитесь к администратору системы. -

+ {/* Top-up dialog */} + + + + Пополнение баланса + + Укажите сумму пополнения. После создания платёж появится в списке платежей. + + +
+ + setAmount(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleTopUp(); + }} + /> +
+ + + + +
+
); } diff --git a/migrations/0080_billing_payments.sql b/migrations/0080_billing_payments.sql new file mode 100644 index 0000000..a86d7b6 --- /dev/null +++ b/migrations/0080_billing_payments.sql @@ -0,0 +1,22 @@ +-- Шаг 0.13 плана production-готовности: платежи самообслуживания биллинга. +-- Пополнение баланса админом организации через платёжного провайдера (сейчас — mock). +-- Без RLS: запись — только серверные роуты (auth + billing.manage) и webhook провайдера, +-- чтение — только через роуты с явным фильтром organization_id. +CREATE TABLE IF NOT EXISTS billing_payments ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider VARCHAR(30) NOT NULL, + external_id VARCHAR(255) NOT NULL, + amount NUMERIC(10,2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + idempotency_key VARCHAR(255) NOT NULL, + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE (external_id), + UNIQUE (idempotency_key) +); + +CREATE INDEX IF NOT EXISTS billing_payments_org_status_idx + ON billing_payments (organization_id, status); diff --git a/server/billing/mock-provider.ts b/server/billing/mock-provider.ts new file mode 100644 index 0000000..298e395 --- /dev/null +++ b/server/billing/mock-provider.ts @@ -0,0 +1,78 @@ +import { createHash, randomUUID, timingSafeEqual } from "crypto"; +import { eq } from "drizzle-orm"; +import { db } from "../db"; +import { billingPayments } from "@shared/schema"; +import type { + PaymentProvider, + CreatePaymentParams, + PaymentCreated, + WebhookVerification, + PaymentStatusInfo, + PaymentStatus, +} from "./payment-provider"; + +// Тестовый провайдер: платежи создаются локально в статусе pending, +// подтверждение — через кнопку «Подтвердить (тест)» или настоящий webhook +// с подписью sha256(externalId + ':' + secret) в заголовке x-mock-signature. + +const DEFAULT_SECRET = "mock-payment-dev-secret"; +const WEBHOOK_STATUSES: PaymentStatus[] = ['pending', 'succeeded', 'canceled']; + +export function getMockSecret(): string { + return process.env.MOCK_PAYMENT_SECRET || DEFAULT_SECRET; +} + +export function signMockWebhook(externalId: string): string { + return createHash("sha256").update(`${externalId}:${getMockSecret()}`).digest("hex"); +} + +export class MockPaymentProvider implements PaymentProvider { + readonly name = "mock"; + + async createPayment(_params: CreatePaymentParams): Promise { + return { + externalId: `mock_${randomUUID()}`, + status: 'pending', + }; + } + + verifyWebhook(headers: Record, body: unknown): WebhookVerification { + const payload = (body ?? {}) as { externalId?: unknown; status?: unknown; idempotencyKey?: unknown }; + const externalId = typeof payload.externalId === "string" ? payload.externalId : undefined; + if (!externalId) { + return { valid: false, error: "Поле externalId отсутствует" }; + } + + const signature = typeof headers["x-mock-signature"] === "string" ? headers["x-mock-signature"] : ""; + const expected = signMockWebhook(externalId); + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) { + return { valid: false, externalId, error: "Неверная подпись webhook" }; + } + + if (!WEBHOOK_STATUSES.includes(payload.status as PaymentStatus)) { + return { valid: false, externalId, error: "Неизвестный статус платежа" }; + } + + return { + valid: true, + externalId, + status: payload.status as PaymentStatus, + idempotencyKey: typeof payload.idempotencyKey === "string" ? payload.idempotencyKey : undefined, + }; + } + + // Статус берём из локальной таблицы платежей — внешней системы у mock нет. + async getPaymentStatus(externalId: string): Promise { + const [row] = await db + .select({ status: billingPayments.status }) + .from(billingPayments) + .where(eq(billingPayments.externalId, externalId)) + .limit(1); + if (!row) { + throw new Error(`Платёж не найден: ${externalId}`); + } + return { externalId, status: row.status as PaymentStatus }; + } +} diff --git a/server/billing/payment-provider.ts b/server/billing/payment-provider.ts new file mode 100644 index 0000000..691359c --- /dev/null +++ b/server/billing/payment-provider.ts @@ -0,0 +1,56 @@ +import { MockPaymentProvider } from "./mock-provider"; + +// Абстракция платёжного провайдера биллинга. +// Подключение реального провайдера (ЮKassa/CloudPayments) = новый класс, +// реализующий PaymentProvider, + case в getPaymentProvider(). Роуты и сервис не меняются. + +export type PaymentStatus = 'pending' | 'succeeded' | 'canceled'; + +export interface CreatePaymentParams { + organizationId: number; + amount: number; // в основной валюте (рубли, не копейки) + currency: string; + idempotencyKey: string; + description?: string; + returnUrl?: string; + metadata?: Record; +} + +export interface PaymentCreated { + externalId: string; + confirmationUrl?: string; + status: PaymentStatus; +} + +export interface WebhookVerification { + valid: boolean; + externalId?: string; + status?: PaymentStatus; + idempotencyKey?: string; + error?: string; +} + +export interface PaymentStatusInfo { + externalId: string; + status: PaymentStatus; +} + +export interface PaymentProvider { + name: string; + createPayment(params: CreatePaymentParams): Promise; + verifyWebhook(headers: Record, body: unknown): WebhookVerification; + getPaymentStatus(externalId: string): Promise; +} + +export function getPaymentProviderName(): string { + return (process.env.PAYMENT_PROVIDER || 'mock').trim().toLowerCase(); +} + +export function getPaymentProvider(name: string = getPaymentProviderName()): PaymentProvider { + switch (name) { + case 'mock': + return new MockPaymentProvider(); + default: + throw new Error(`Неизвестный платёжный провайдер: ${name}`); + } +} diff --git a/server/billing/payments.service.ts b/server/billing/payments.service.ts new file mode 100644 index 0000000..eb8b839 --- /dev/null +++ b/server/billing/payments.service.ts @@ -0,0 +1,144 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "../db"; +import { billingPayments, billingTransactions, organizationBilling, organizations } from "@shared/schema"; +import type { BillingPayment } from "@shared/schema"; + +// Сервис платежей биллинга: создание, выборки и зачисление успешных платежей. + +export interface CreateBillingPaymentParams { + organizationId: number; + provider: string; + externalId: string; + amount: number; + currency: string; + idempotencyKey: string; + metadata?: Record; +} + +// Создание платежа с защитой от дублей: повторный вызов с тем же +// idempotency_key возвращает существующую запись, а не создаёт новую. +export async function createBillingPayment(params: CreateBillingPaymentParams): Promise { + const [row] = await db + .insert(billingPayments) + .values({ + organizationId: params.organizationId, + provider: params.provider, + externalId: params.externalId, + amount: params.amount.toFixed(2), + currency: params.currency, + status: 'pending', + idempotencyKey: params.idempotencyKey, + metadata: params.metadata ?? null, + }) + .onConflictDoNothing({ target: billingPayments.idempotencyKey }) + .returning(); + if (row) return row; + const [existing] = await db + .select() + .from(billingPayments) + .where(eq(billingPayments.idempotencyKey, params.idempotencyKey)) + .limit(1); + return existing; +} + +export async function listBillingPayments(organizationId: number, limit = 50): Promise { + return db + .select() + .from(billingPayments) + .where(eq(billingPayments.organizationId, organizationId)) + .orderBy(desc(billingPayments.createdAt)) + .limit(limit); +} + +export async function getBillingPaymentById(id: number, organizationId?: number): Promise { + const conditions = organizationId !== undefined + ? and(eq(billingPayments.id, id), eq(billingPayments.organizationId, organizationId)) + : eq(billingPayments.id, id); + const [row] = await db.select().from(billingPayments).where(conditions).limit(1); + return row ?? null; +} + +export async function getBillingPaymentByExternalId(externalId: string): Promise { + const [row] = await db + .select() + .from(billingPayments) + .where(eq(billingPayments.externalId, externalId)) + .limit(1); + return row ?? null; +} + +export async function markBillingPaymentCanceled(id: number): Promise { + const [row] = await db + .update(billingPayments) + .set({ status: 'canceled', updatedAt: new Date() }) + .where(and(eq(billingPayments.id, id), eq(billingPayments.status, 'pending'))) + .returning(); + return row ?? null; +} + +// Зачисление успешного платежа в одной транзакции: +// 1) атомарный перевод pending→succeeded (guard от гонок и повторных webhook'ов); +// 2) credit-запись в billing_transactions; +// 3) инкремент organization_billing.balance; +// 4) авто-снятие billingBlocked при положительном балансе +// (та же пара обновлений, что и storage.setBillingBlocked(orgId, false)). +// Возвращает applied=false, если платёж уже обработан — повторное зачисление невозможно. +export async function applySucceededPayment( + paymentId: number, +): Promise<{ applied: boolean; payment: BillingPayment | null }> { + return db.transaction(async (tx) => { + const [payment] = await tx + .update(billingPayments) + .set({ status: 'succeeded', updatedAt: new Date() }) + .where(and(eq(billingPayments.id, paymentId), eq(billingPayments.status, 'pending'))) + .returning(); + + if (!payment) { + const [current] = await tx + .select() + .from(billingPayments) + .where(eq(billingPayments.id, paymentId)) + .limit(1); + return { applied: false, payment: current ?? null }; + } + + const orgId = payment.organizationId; + + // Гарантируем наличие строки биллинга организации перед инкрементом. + await tx + .insert(organizationBilling) + .values({ organizationId: orgId }) + .onConflictDoNothing({ target: organizationBilling.organizationId }); + + const [billing] = await tx + .update(organizationBilling) + .set({ + balance: sql`${organizationBilling.balance} + ${payment.amount}::numeric`, + updatedAt: new Date(), + }) + .where(eq(organizationBilling.organizationId, orgId)) + .returning(); + + await tx.insert(billingTransactions).values({ + organizationId: orgId, + amount: payment.amount, + type: 'credit', + description: `Пополнение баланса (платёж ${payment.provider} ${payment.externalId})`, + createdBy: null, + }); + + const newBalance = parseFloat(billing?.balance ?? '0'); + if (newBalance > 0) { + await tx + .update(organizations) + .set({ billingBlocked: false, updatedAt: new Date() }) + .where(and(eq(organizations.id, orgId), eq(organizations.billingBlocked, true))); + await tx + .update(organizationBilling) + .set({ blockedAt: null, updatedAt: new Date() }) + .where(eq(organizationBilling.organizationId, orgId)); + } + + return { applied: true, payment }; + }); +} diff --git a/server/routes/admin.routes.ts b/server/routes/admin.routes.ts index d291026..30b0232 100644 --- a/server/routes/admin.routes.ts +++ b/server/routes/admin.routes.ts @@ -311,7 +311,8 @@ const router = Router(); router.get('/api/billing/summary', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => { try { const summary = await storage.getBillingSummary(req.organizationId!); - return res.json({ success: true, summary }); + const { getPaymentProviderName } = await import('../billing/payment-provider'); + return res.json({ success: true, summary: { ...summary, paymentProvider: getPaymentProviderName() } }); } catch (error) { console.error('Billing summary error:', error); return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); diff --git a/server/routes/billing-payments.routes.ts b/server/routes/billing-payments.routes.ts new file mode 100644 index 0000000..b4f087a --- /dev/null +++ b/server/routes/billing-payments.routes.ts @@ -0,0 +1,137 @@ +import { Router } from "express"; +import { randomUUID } from "crypto"; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; +import { getPaymentProvider, getPaymentProviderName } from "../billing/payment-provider"; +import { + createBillingPayment, + listBillingPayments, + getBillingPaymentById, + getBillingPaymentByExternalId, + markBillingPaymentCanceled, + applySucceededPayment, +} from "../billing/payments.service"; + +// Самообслуживание оплаты: создание платежей админом организации, +// тестовое подтверждение (mock-провайдер) и публичные webhook'и провайдера. +const router = Router(); + +const MAX_PAYMENT_AMOUNT = 10_000_000; + +// POST /api/billing/payments — создать платёж на пополнение баланса. +// Идемпотентность: заголовок Idempotency-Key (опционально), иначе генерируется ключ. +router.post('/api/billing/payments', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => { + try { + const amountNum = parseFloat(String(req.body?.amount)); + if (!amountNum || isNaN(amountNum) || amountNum <= 0) { + return res.status(400).json({ success: false, error: 'Сумма должна быть положительным числом' }); + } + if (amountNum > MAX_PAYMENT_AMOUNT) { + return res.status(400).json({ success: false, error: 'Сумма превышает максимально допустимую' }); + } + + const orgId = req.organizationId!; + const provider = getPaymentProvider(); + + const headerKey = req.headers['idempotency-key']; + const idempotencyKey = typeof headerKey === 'string' && headerKey.trim() + ? `org:${orgId}:${headerKey.trim()}`.slice(0, 255) + : `org:${orgId}:payment:${randomUUID()}`; + + const created = await provider.createPayment({ + organizationId: orgId, + amount: amountNum, + currency: 'RUB', + idempotencyKey, + description: 'Пополнение баланса', + }); + + const payment = await createBillingPayment({ + organizationId: orgId, + provider: provider.name, + externalId: created.externalId, + amount: amountNum, + currency: 'RUB', + idempotencyKey, + }); + + return res.status(201).json({ + success: true, + payment, + confirmationUrl: created.confirmationUrl ?? null, + }); + } catch (error) { + console.error('Billing payment create error:', error); + return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); + } +}); + +// GET /api/billing/payments — список платежей организации (новые первыми). +router.get('/api/billing/payments', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => { + try { + const payments = await listBillingPayments(req.organizationId!, 50); + return res.json({ success: true, payments }); + } catch (error) { + console.error('Billing payments list error:', error); + return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); + } +}); + +// POST /api/billing/payments/:id/confirm-test — тестовое подтверждение платежа. +// Доступно только при PAYMENT_PROVIDER=mock; в остальных режимах маршрут скрыт (404). +router.post('/api/billing/payments/:id/confirm-test', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => { + try { + if (getPaymentProviderName() !== 'mock') { + return res.status(404).json({ success: false, error: 'Маршрут недоступен' }); + } + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ success: false, error: 'Некорректный ID' }); + } + const payment = await getBillingPaymentById(id, req.organizationId!); + if (!payment) { + return res.status(404).json({ success: false, error: 'Платёж не найден' }); + } + const result = await applySucceededPayment(payment.id); + return res.json({ success: true, applied: result.applied, payment: result.payment }); + } catch (error) { + console.error('Billing payment confirm-test error:', error); + return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); + } +}); + +// POST /api/billing/webhooks/:provider — публичный webhook платёжного провайдера (без auth). +// Подпись проверяется провайдером; повторный webhook по уже обработанному +// платежу не зачисляет средства дважды (guard в applySucceededPayment). +router.post('/api/billing/webhooks/:provider', async (req, res) => { + let provider; + try { + provider = getPaymentProvider(String(req.params.provider).toLowerCase()); + } catch { + return res.status(404).json({ success: false, error: 'Неизвестный провайдер' }); + } + + try { + const verification = provider.verifyWebhook(req.headers as Record, req.body); + if (!verification.valid || !verification.externalId) { + return res.status(401).json({ success: false, error: verification.error || 'Неверная подпись webhook' }); + } + + const payment = await getBillingPaymentByExternalId(verification.externalId); + if (!payment) { + return res.status(404).json({ success: false, error: 'Платёж не найден' }); + } + + if (verification.status === 'succeeded') { + await applySucceededPayment(payment.id); + } else if (verification.status === 'canceled') { + await markBillingPaymentCanceled(payment.id); + } + + return res.json({ success: true }); + } catch (error) { + console.error('Billing webhook error:', error); + return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); + } +}); + +export default router; diff --git a/server/routes/index.ts b/server/routes/index.ts index 4206fad..b5b046c 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -10,6 +10,7 @@ import taskRouter from "./task.routes"; import chatRouter from "./chat.routes"; import automationRouter from "./automation.routes"; import adminRouter from "./admin.routes"; +import billingPaymentsRouter from "./billing-payments.routes"; import messengerRouter from "./messenger.routes"; import reactionsRouter from "./reactions.routes"; import pollsRouter from "./polls.routes"; @@ -89,6 +90,7 @@ export async function registerRoutes(app: Express): Promise { app.use(chatRouter); app.use(automationRouter); app.use(adminRouter); + app.use(billingPaymentsRouter); app.use(messengerRouter); app.use(reactionsRouter); app.use(pollsRouter); diff --git a/shared/schema.ts b/shared/schema.ts index ba4cac4..bf6ffe5 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -87,6 +87,26 @@ export const billingTransactions = pgTable("billing_transactions", { export type BillingTransaction = typeof billingTransactions.$inferSelect; export type InsertBillingTransaction = typeof billingTransactions.$inferInsert; +// Billing payments (self-service top-ups via payment provider) +export const billingPayments = pgTable("billing_payments", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + provider: varchar("provider", { length: 30 }).notNull(), + externalId: varchar("external_id", { length: 255 }).notNull().unique("billing_payments_external_id_key"), + amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), + currency: varchar("currency", { length: 10 }).notNull().default("RUB"), + status: varchar("status", { length: 20 }).notNull().default("pending"), // 'pending' | 'succeeded' | 'canceled' + idempotencyKey: varchar("idempotency_key", { length: 255 }).notNull().unique("billing_payments_idempotency_key_key"), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}, (table) => ({ + orgStatusIdx: index("billing_payments_org_status_idx").on(table.organizationId, table.status), +})); + +export type BillingPayment = typeof billingPayments.$inferSelect; +export type InsertBillingPayment = typeof billingPayments.$inferInsert; + // Users table export const users = pgTable("users", { id: serial("id").primaryKey(), diff --git a/tests/billing-payments.test.ts b/tests/billing-payments.test.ts new file mode 100644 index 0000000..a1fd756 --- /dev/null +++ b/tests/billing-payments.test.ts @@ -0,0 +1,360 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +const mockStorage = vi.hoisted(() => ({ + getUserWithOrganization: vi.fn(), +})); + +// In-memory fake payments.service: воспроизводит контракт настоящего сервиса, +// включая идемпотентность зачисления (pending→succeeded только один раз). +const fake = vi.hoisted(() => { + const state = { + payments: new Map(), + nextId: 1, + credits: [] as Array<{ organizationId: number; amount: string }>, + }; + return { state }; +}); + +vi.mock('../server/db', () => ({ + db: { + // Цепочка для loadPermissionCache в auth.middleware (requirePermission). + select: () => ({ + from: () => ({ + innerJoin: () => ({ + innerJoin: () => Promise.resolve([{ appRoleSlug: 'admin', permissionCode: 'billing.manage' }]), + }), + }), + }), + // Заглушка для trackUserActivity (best-effort обновление активности). + update: () => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + }), + }, + pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, + withTenant: (_orgId: number, fn: () => unknown) => fn(), + openTenantCtx: vi.fn().mockResolvedValue({ + run: (fn: () => void) => fn(), + release: vi.fn(), + }), + openSuperAdminCtx: vi.fn().mockResolvedValue(undefined), + _tenantCtx: { getStore: vi.fn().mockReturnValue(null) }, +})); + +vi.mock('../server/storage', () => ({ storage: mockStorage })); + +vi.mock('../server/services/notification.service', () => ({ + notificationService: { + emit: vi.fn(), + on: vi.fn(), + sendNotification: vi.fn().mockResolvedValue(undefined), + processEvent: vi.fn().mockResolvedValue(undefined), + }, + EVENT_TYPES: {}, +})); + +vi.mock('../server/services/webhook.service', () => ({ + webhookService: { + dispatchEvent: vi.fn().mockResolvedValue(undefined), + processWebhook: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../server/utils/webhook', () => ({ + sendWebhook: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../server/utils/s3', () => ({ + isS3Enabled: false, + ensureS3Bucket: vi.fn().mockResolvedValue(undefined), + streamFromS3: vi.fn().mockResolvedValue(null), + deleteFromS3: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../server/utils/audit', () => ({ + logAudit: vi.fn().mockResolvedValue(undefined), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})); + +vi.mock('../server/billing/payments.service', () => ({ + createBillingPayment: vi.fn(async (p: any) => { + for (const row of fake.state.payments.values()) { + if (row.idempotencyKey === p.idempotencyKey) return row; + } + const row = { + id: fake.state.nextId++, + status: 'pending', + ...p, + amount: p.amount.toFixed(2), + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + fake.state.payments.set(row.id, row); + return row; + }), + listBillingPayments: vi.fn(async (orgId: number) => + [...fake.state.payments.values()].filter((p) => p.organizationId === orgId)), + getBillingPaymentById: vi.fn(async (id: number, orgId?: number) => { + const p = fake.state.payments.get(id) ?? null; + return p && (orgId === undefined || p.organizationId === orgId) ? p : null; + }), + getBillingPaymentByExternalId: vi.fn(async (externalId: string) => + [...fake.state.payments.values()].find((p) => p.externalId === externalId) ?? null), + markBillingPaymentCanceled: vi.fn(async (id: number) => { + const p = fake.state.payments.get(id) ?? null; + if (p && p.status === 'pending') p.status = 'canceled'; + return p; + }), + applySucceededPayment: vi.fn(async (id: number) => { + const p = fake.state.payments.get(id) ?? null; + if (!p || p.status !== 'pending') return { applied: false, payment: p }; + p.status = 'succeeded'; + fake.state.credits.push({ organizationId: p.organizationId, amount: p.amount }); + return { applied: true, payment: p }; + }), +})); + +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import billingPaymentsRouter from '../server/routes/billing-payments.routes'; +import { signMockWebhook } from '../server/billing/mock-provider'; + +const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET as string; + +function makeValidToken(userId: number, organizationId: number): string { + return jwt.sign( + { userId, organizationId, role: 'user' }, + ACCESS_SECRET, + { issuer: 'workflow-system', audience: 'workflow-users', expiresIn: '1h' }, + ); +} + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use(billingPaymentsRouter); + return app; +} + +const TEST_USER = { + id: 42, + email: 'admin@example.com', + firstName: 'Admin', + lastName: 'User', + appRole: 'admin', + role: 'user', + isActive: true, + organizationId: 7, + organization: { id: 7, isActive: true, billingBlocked: true }, +}; + +const app = buildApp(); + +function authHeader(): [string, string] { + return ['Authorization', `Bearer ${makeValidToken(TEST_USER.id, TEST_USER.organizationId)}`]; +} + +async function createPayment(amount = 1500): Promise { + const res = await request(app) + .post('/api/billing/payments') + .set(...authHeader()) + .send({ amount }); + expect(res.status).toBe(201); + return res.body.payment; +} + +beforeEach(() => { + fake.state.payments.clear(); + fake.state.nextId = 1; + fake.state.credits.length = 0; + mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER); + delete process.env.PAYMENT_PROVIDER; +}); + +afterEach(() => { + delete process.env.PAYMENT_PROVIDER; +}); + +describe('POST /api/billing/payments', () => { + it('создаёт платёж в статусе pending (201)', async () => { + const res = await request(app) + .post('/api/billing/payments') + .set(...authHeader()) + .send({ amount: 1500 }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.payment).toMatchObject({ + organizationId: TEST_USER.organizationId, + provider: 'mock', + status: 'pending', + amount: '1500.00', + currency: 'RUB', + }); + expect(res.body.payment.externalId).toMatch(/^mock_/); + }); + + it('возвращает 401 без токена', async () => { + const res = await request(app).post('/api/billing/payments').send({ amount: 100 }); + expect(res.status).toBe(401); + }); + + it('возвращает 400 при некорректной сумме', async () => { + const res = await request(app) + .post('/api/billing/payments') + .set(...authHeader()) + .send({ amount: -50 }); + expect(res.status).toBe(400); + }); + + it('повторный запрос с тем же Idempotency-Key возвращает тот же платёж', async () => { + const first = await request(app) + .post('/api/billing/payments') + .set(...authHeader()) + .set('Idempotency-Key', 'topup-abc-1') + .send({ amount: 500 }); + const second = await request(app) + .post('/api/billing/payments') + .set(...authHeader()) + .set('Idempotency-Key', 'topup-abc-1') + .send({ amount: 500 }); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(second.body.payment.id).toBe(first.body.payment.id); + expect(fake.state.payments.size).toBe(1); + }); +}); + +describe('GET /api/billing/payments', () => { + it('возвращает платежи только своей организации', async () => { + await createPayment(100); + await createPayment(200); + + const res = await request(app) + .get('/api/billing/payments') + .set(...authHeader()); + + expect(res.status).toBe(200); + expect(res.body.payments).toHaveLength(2); + }); +}); + +describe('POST /api/billing/payments/:id/confirm-test', () => { + it('подтверждает платёж и зачисляет средства (снятие блокировки — внутри зачисления)', async () => { + const payment = await createPayment(1500); + + const res = await request(app) + .post(`/api/billing/payments/${payment.id}/confirm-test`) + .set(...authHeader()); + + expect(res.status).toBe(200); + expect(res.body.applied).toBe(true); + expect(res.body.payment.status).toBe('succeeded'); + expect(fake.state.credits).toEqual([ + { organizationId: TEST_USER.organizationId, amount: '1500.00' }, + ]); + }); + + it('возвращает 404 для платежа чужой организации', async () => { + const payment = await createPayment(100); + const otherToken = makeValidToken(55, 999); + mockStorage.getUserWithOrganization.mockResolvedValue({ + ...TEST_USER, + id: 55, + organizationId: 999, + organization: { id: 999, isActive: true, billingBlocked: false }, + }); + + const res = await request(app) + .post(`/api/billing/payments/${payment.id}/confirm-test`) + .set('Authorization', `Bearer ${otherToken}`); + + expect(res.status).toBe(404); + expect(fake.state.credits).toHaveLength(0); + }); + + it('возвращает 404 при не-mock провайдере', async () => { + const payment = await createPayment(100); + process.env.PAYMENT_PROVIDER = 'yookassa'; + + const res = await request(app) + .post(`/api/billing/payments/${payment.id}/confirm-test`) + .set(...authHeader()); + + expect(res.status).toBe(404); + expect(fake.state.credits).toHaveLength(0); + }); +}); + +describe('POST /api/billing/webhooks/:provider', () => { + it('webhook с валидной подписью зачисляет платёж', async () => { + const payment = await createPayment(1500); + + const res = await request(app) + .post('/api/billing/webhooks/mock') + .set('x-mock-signature', signMockWebhook(payment.externalId)) + .send({ externalId: payment.externalId, status: 'succeeded' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(fake.state.payments.get(payment.id)?.status).toBe('succeeded'); + expect(fake.state.credits).toEqual([ + { organizationId: TEST_USER.organizationId, amount: '1500.00' }, + ]); + }); + + it('повторный webhook не зачисляет дважды (идемпотентность)', async () => { + const payment = await createPayment(1500); + const signature = signMockWebhook(payment.externalId); + const body = { externalId: payment.externalId, status: 'succeeded' }; + + const first = await request(app) + .post('/api/billing/webhooks/mock') + .set('x-mock-signature', signature) + .send(body); + const second = await request(app) + .post('/api/billing/webhooks/mock') + .set('x-mock-signature', signature) + .send(body); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(fake.state.credits).toHaveLength(1); + }); + + it('webhook с невалидной подписью отклоняется (401)', async () => { + const payment = await createPayment(1500); + + const res = await request(app) + .post('/api/billing/webhooks/mock') + .set('x-mock-signature', 'deadbeef'.repeat(8)) + .send({ externalId: payment.externalId, status: 'succeeded' }); + + expect(res.status).toBe(401); + expect(fake.state.payments.get(payment.id)?.status).toBe('pending'); + expect(fake.state.credits).toHaveLength(0); + }); + + it('webhook без подписи отклоняется (401)', async () => { + const payment = await createPayment(1500); + + const res = await request(app) + .post('/api/billing/webhooks/mock') + .send({ externalId: payment.externalId, status: 'succeeded' }); + + expect(res.status).toBe(401); + expect(fake.state.credits).toHaveLength(0); + }); + + it('webhook для неизвестного провайдера возвращает 404', async () => { + const res = await request(app) + .post('/api/billing/webhooks/unknown-provider') + .send({ externalId: 'mock_whatever', status: 'succeeded' }); + + expect(res.status).toBe(404); + }); +});