Files
iistwin/server/billing/mock-provider.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

79 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}
}