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

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