Шаг 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)
138 lines
6.1 KiB
TypeScript
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;
|