feat(billing): PaymentProvider + Mock, self-service пополнение баланса

Шаг 0.13 плана production-готовности:
- интерфейс PaymentProvider (createPayment/verifyWebhook/getPaymentStatus)
- MockPaymentProvider с настоящим webhook (sha256-подпись, идемпотентность)
- applySucceededPayment: транзакция с guard, credit, авто-снятие billingBlocked
- роуты payments + confirm-test (только mock) + публичный webhook
- Billing.tsx: Пополнить/Подтвердить (тест), фикс setLocation в рендере
- миграция 0080 billing_payments, 13 новых тестов (78/78)
This commit is contained in:
2026-09-07 22:45:30 +03:00
parent 23c49adcd1
commit 701af06290
11 changed files with 1039 additions and 14 deletions

View File

@@ -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_<uuid>, 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).

View File

@@ -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<BillingPayment["status"], string> = {
pending: "Ожидает оплаты",
succeeded: "Оплачен",
canceled: "Отменён",
};
const PAYMENT_STATUS_CLASSES: Record<BillingPayment["status"], string> = {
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 (
<div className="p-6 max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Подписка и биллинг</h1>
<p className="text-muted-foreground">Управление балансом и история операций</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Подписка и биллинг</h1>
<p className="text-muted-foreground">Управление балансом и история операций</p>
</div>
<Button onClick={() => setTopUpOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Пополнить
</Button>
</div>
{summary?.billingBlocked && (
@@ -68,7 +180,7 @@ export default function Billing() {
<div>
<p className="font-semibold text-red-700 dark:text-red-400">Доступ приостановлен</p>
<p className="text-sm text-red-600 dark:text-red-300">
Баланс организации исчерпан. Обратитесь к администратору системы для пополнения баланса.
Баланс организации исчерпан. Пополните баланс — доступ восстановится автоматически.
</p>
</div>
</div>
@@ -158,6 +270,51 @@ export default function Billing() {
</Card>
</div>
{/* Payments */}
<Card>
<CardHeader>
<CardTitle className="text-base">Платежи</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loadingPayments ? (
<div className="p-8 text-center text-muted-foreground">Загрузка...</div>
) : payments.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">Платежей пока нет</div>
) : (
<div className="divide-y">
{payments.map((payment) => (
<div key={payment.id} className="flex items-center justify-between px-4 py-3 gap-3">
<div>
<p className="text-sm font-medium">
{formatAmount(payment.amount)} {payment.currency}
<span className="text-muted-foreground font-normal"> · {payment.provider}</span>
</p>
<p className="text-xs text-muted-foreground">
{payment.createdAt ? format(new Date(payment.createdAt), "d MMM yyyy, HH:mm", { locale: ru }) : ""}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="outline" className={PAYMENT_STATUS_CLASSES[payment.status]}>
{PAYMENT_STATUS_LABELS[payment.status]}
</Badge>
{payment.status === "pending" && isMockProvider && (
<Button
size="sm"
variant="outline"
disabled={confirmTestMutation.isPending}
onClick={() => confirmTestMutation.mutate(payment.id)}
>
Подтвердить (тест)
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Transaction history */}
<Card>
<CardHeader>
@@ -195,9 +352,39 @@ export default function Billing() {
</CardContent>
</Card>
<p className="text-xs text-muted-foreground text-center">
Для пополнения баланса обратитесь к администратору системы.
</p>
{/* Top-up dialog */}
<Dialog open={topUpOpen} onOpenChange={setTopUpOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Пополнение баланса</DialogTitle>
<DialogDescription>
Укажите сумму пополнения. После создания платёж появится в списке платежей.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="topup-amount">Сумма, {currency}</Label>
<Input
id="topup-amount"
type="text"
inputMode="decimal"
placeholder="Например, 1000"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleTopUp();
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTopUpOpen(false)}>
Отмена
</Button>
<Button onClick={handleTopUp} disabled={createPaymentMutation.isPending}>
{createPaymentMutation.isPending ? "Создание..." : "Создать платёж"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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);

View File

@@ -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<PaymentCreated> {
return {
externalId: `mock_${randomUUID()}`,
status: 'pending',
};
}
verifyWebhook(headers: Record<string, unknown>, 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<PaymentStatusInfo> {
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 };
}
}

View File

@@ -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<string, unknown>;
}
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<PaymentCreated>;
verifyWebhook(headers: Record<string, unknown>, body: unknown): WebhookVerification;
getPaymentStatus(externalId: string): Promise<PaymentStatusInfo>;
}
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}`);
}
}

View File

@@ -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<string, unknown>;
}
// Создание платежа с защитой от дублей: повторный вызов с тем же
// idempotency_key возвращает существующую запись, а не создаёт новую.
export async function createBillingPayment(params: CreateBillingPaymentParams): Promise<BillingPayment> {
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<BillingPayment[]> {
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<BillingPayment | null> {
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<BillingPayment | null> {
const [row] = await db
.select()
.from(billingPayments)
.where(eq(billingPayments.externalId, externalId))
.limit(1);
return row ?? null;
}
export async function markBillingPaymentCanceled(id: number): Promise<BillingPayment | null> {
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 };
});
}

View File

@@ -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: 'Внутренняя ошибка сервера' });

View File

@@ -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<string, unknown>, 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;

View File

@@ -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<Server> {
app.use(chatRouter);
app.use(automationRouter);
app.use(adminRouter);
app.use(billingPaymentsRouter);
app.use(messengerRouter);
app.use(reactionsRouter);
app.use(pollsRouter);

View File

@@ -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(),

View File

@@ -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<number, any>(),
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<any> {
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);
});
});