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:
78
server/billing/mock-provider.ts
Normal file
78
server/billing/mock-provider.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
56
server/billing/payment-provider.ts
Normal file
56
server/billing/payment-provider.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
144
server/billing/payments.service.ts
Normal file
144
server/billing/payments.service.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -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