Files
iistwin/server/routes/billing-payments.routes.ts
Ильяс Султанов 701af06290 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)
2026-09-07 22:45:30 +03:00

138 lines
6.1 KiB
TypeScript

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;