Шаг 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)
79 lines
3.0 KiB
TypeScript
79 lines
3.0 KiB
TypeScript
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 };
|
||
}
|
||
}
|