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:
@@ -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: 'Внутренняя ошибка сервера' });
|
||||
|
||||
137
server/routes/billing-payments.routes.ts
Normal file
137
server/routes/billing-payments.routes.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user