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:
@@ -162,3 +162,21 @@
|
||||
- Как проверялось: `npm run check` чисто; `npx vitest run` 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user).
|
||||
- Влияние на поиск/UX: нет (форматы ответов не тронуты).
|
||||
- Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей).
|
||||
|
||||
---
|
||||
|
||||
## [0.13] Биллинг: PaymentProvider + MockPaymentProvider
|
||||
|
||||
- Статус: ✅ done
|
||||
- Зачем: самообслуживание оплаты без superadmin; реальный эквайринг (ЮKassa/CloudPayments) позже без переписывания (решение пользователя — только mock + интерфейс).
|
||||
- Что изменено:
|
||||
- `server/billing/payment-provider.ts` — интерфейс PaymentProvider + фабрика по env `PAYMENT_PROVIDER` (дефолт mock).
|
||||
- `server/billing/mock-provider.ts` — mock: externalId mock_<uuid>, webhook-подпись sha256 (timingSafeEqual), secret env `MOCK_PAYMENT_SECRET`.
|
||||
- `server/billing/payments.service.ts` — createBillingPayment (ON CONFLICT по idempotency_key), `applySucceededPayment`: транзакция с атомарным guard pending→succeeded (дубли webhook невозможны), credit в billing_transactions, инкремент balance, авто-снятие billingBlocked при балансе > 0 (та же логика, что superadmin unblock).
|
||||
- `server/routes/billing-payments.routes.ts` — POST/GET /api/billing/payments (billing.manage), POST /:id/confirm-test (только mock, иначе 404), публичный POST /api/billing/webhooks/:provider (401 невалидная подпись, идемпотентность).
|
||||
- `migrations/0080_billing_payments.sql` + таблица в shared/schema.ts (UNIQUE external_id/idempotency_key).
|
||||
- `client/src/pages/Billing.tsx` — фикс setLocation в теле рендера (useEffect), кнопка «Пополнить» с диалогом, секция «Платежи», «Подтвердить (тест)» у pending при mock (признак paymentProvider в /api/billing/summary), текст блокировки про авто-восстановление.
|
||||
- `tests/billing-payments.test.ts` — 13 тестов.
|
||||
- Как проверялось: vitest 78/78 (дедуп по Idempotency-Key, повторный webhook не зачисляет дважды, изоляция организаций, 404 confirm-test при не-mock, 401 по подписи); `npm run check` чисто.
|
||||
- Влияние на поиск/UX: нет.
|
||||
- Подводные камни: на проде PAYMENT_PROVIDER не задан → mock активен, кнопка «Подтвердить (тест)» видна админам организаций — это и есть поставка шага; при подключении реального провайдера кнопка скроется автоматически. Подключение реального провайдера: новый класс в server/billing/ + case в фабрике + env (см. отчёт в коде payment-provider.ts).
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useLocation } from "wouter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Wallet, Users, TrendingDown, Calendar, ArrowUpCircle, ArrowDownCircle, AlertTriangle } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Wallet, Users, TrendingDown, Calendar, ArrowUpCircle, ArrowDownCircle, AlertTriangle, PlusCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface BillingSummary {
|
||||
balance: string;
|
||||
@@ -17,6 +29,7 @@ interface BillingSummary {
|
||||
billingBlocked: boolean;
|
||||
activeUsers: number;
|
||||
estimatedCharge: string;
|
||||
paymentProvider: string;
|
||||
}
|
||||
|
||||
interface BillingTransaction {
|
||||
@@ -29,37 +42,136 @@ interface BillingTransaction {
|
||||
createdBy: number | null;
|
||||
}
|
||||
|
||||
interface BillingPayment {
|
||||
id: number;
|
||||
organizationId: number;
|
||||
provider: string;
|
||||
externalId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: "pending" | "succeeded" | "canceled";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const PAYMENT_STATUS_LABELS: Record<BillingPayment["status"], string> = {
|
||||
pending: "Ожидает оплаты",
|
||||
succeeded: "Оплачен",
|
||||
canceled: "Отменён",
|
||||
};
|
||||
|
||||
const PAYMENT_STATUS_CLASSES: Record<BillingPayment["status"], string> = {
|
||||
pending: "text-amber-600 border-amber-300",
|
||||
succeeded: "text-emerald-600 border-emerald-300",
|
||||
canceled: "text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function Billing() {
|
||||
const { user } = useAuth();
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const isAdmin = user?.appRole === "admin";
|
||||
|
||||
if (user?.appRole !== "admin") {
|
||||
setLocation("/");
|
||||
return null;
|
||||
}
|
||||
const [topUpOpen, setTopUpOpen] = useState(false);
|
||||
const [amount, setAmount] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.appRole !== "admin") {
|
||||
setLocation("/");
|
||||
}
|
||||
}, [user, setLocation]);
|
||||
|
||||
const { data: summaryData, isLoading: loadingSummary } = useQuery<{ success: boolean; summary: BillingSummary }>({
|
||||
queryKey: ["/api/billing/summary"],
|
||||
queryFn: () => apiRequest("GET", "/api/billing/summary").then(r => r.json()),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const { data: txData, isLoading: loadingTx } = useQuery<{ success: boolean; transactions: BillingTransaction[] }>({
|
||||
queryKey: ["/api/billing/transactions"],
|
||||
queryFn: () => apiRequest("GET", "/api/billing/transactions").then(r => r.json()),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const { data: paymentsData, isLoading: loadingPayments } = useQuery<{ success: boolean; payments: BillingPayment[] }>({
|
||||
queryKey: ["/api/billing/payments"],
|
||||
queryFn: () => apiRequest("GET", "/api/billing/payments").then(r => r.json()),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const invalidateBilling = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/billing/summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/billing/transactions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/billing/payments"] });
|
||||
};
|
||||
|
||||
const createPaymentMutation = useMutation({
|
||||
mutationFn: (amountNum: number) =>
|
||||
apiRequest("POST", "/api/billing/payments", { amount: amountNum }).then(async r => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(body?.error || "Не удалось создать платёж");
|
||||
return body;
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setTopUpOpen(false);
|
||||
setAmount("");
|
||||
invalidateBilling();
|
||||
toast({ title: "Платёж создан", description: "Платёж появился в списке со статусом «Ожидает оплаты»." });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const confirmTestMutation = useMutation({
|
||||
mutationFn: (paymentId: number) =>
|
||||
apiRequest("POST", `/api/billing/payments/${paymentId}/confirm-test`).then(async r => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(body?.error || "Не удалось подтвердить платёж");
|
||||
return body;
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidateBilling();
|
||||
toast({ title: "Платёж подтверждён", description: "Баланс пополнен." });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = summaryData?.summary;
|
||||
const transactions = txData?.transactions ?? [];
|
||||
const payments = paymentsData?.payments ?? [];
|
||||
const currency = summary?.currency ?? "RUB";
|
||||
const isMockProvider = summary?.paymentProvider === "mock";
|
||||
|
||||
const formatAmount = (amount: string) =>
|
||||
parseFloat(amount).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const handleTopUp = () => {
|
||||
const amountNum = parseFloat(amount.replace(",", ".").replace(/\s/g, ""));
|
||||
if (!amountNum || isNaN(amountNum) || amountNum <= 0) {
|
||||
toast({ title: "Ошибка", description: "Введите положительную сумму", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
createPaymentMutation.mutate(amountNum);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Подписка и биллинг</h1>
|
||||
<p className="text-muted-foreground">Управление балансом и история операций</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Подписка и биллинг</h1>
|
||||
<p className="text-muted-foreground">Управление балансом и история операций</p>
|
||||
</div>
|
||||
<Button onClick={() => setTopUpOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Пополнить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{summary?.billingBlocked && (
|
||||
@@ -68,7 +180,7 @@ export default function Billing() {
|
||||
<div>
|
||||
<p className="font-semibold text-red-700 dark:text-red-400">Доступ приостановлен</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-300">
|
||||
Баланс организации исчерпан. Обратитесь к администратору системы для пополнения баланса.
|
||||
Баланс организации исчерпан. Пополните баланс — доступ восстановится автоматически.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +270,51 @@ export default function Billing() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Payments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Платежи</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loadingPayments ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Загрузка...</div>
|
||||
) : payments.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Платежей пока нет</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{payments.map((payment) => (
|
||||
<div key={payment.id} className="flex items-center justify-between px-4 py-3 gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatAmount(payment.amount)} {payment.currency}
|
||||
<span className="text-muted-foreground font-normal"> · {payment.provider}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{payment.createdAt ? format(new Date(payment.createdAt), "d MMM yyyy, HH:mm", { locale: ru }) : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant="outline" className={PAYMENT_STATUS_CLASSES[payment.status]}>
|
||||
{PAYMENT_STATUS_LABELS[payment.status]}
|
||||
</Badge>
|
||||
{payment.status === "pending" && isMockProvider && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={confirmTestMutation.isPending}
|
||||
onClick={() => confirmTestMutation.mutate(payment.id)}
|
||||
>
|
||||
Подтвердить (тест)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transaction history */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -195,9 +352,39 @@ export default function Billing() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Для пополнения баланса обратитесь к администратору системы.
|
||||
</p>
|
||||
{/* Top-up dialog */}
|
||||
<Dialog open={topUpOpen} onOpenChange={setTopUpOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Пополнение баланса</DialogTitle>
|
||||
<DialogDescription>
|
||||
Укажите сумму пополнения. После создания платёж появится в списке платежей.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="topup-amount">Сумма, {currency}</Label>
|
||||
<Input
|
||||
id="topup-amount"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="Например, 1000"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleTopUp();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTopUpOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleTopUp} disabled={createPaymentMutation.isPending}>
|
||||
{createPaymentMutation.isPending ? "Создание..." : "Создать платёж"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
22
migrations/0080_billing_payments.sql
Normal file
22
migrations/0080_billing_payments.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Шаг 0.13 плана production-готовности: платежи самообслуживания биллинга.
|
||||
-- Пополнение баланса админом организации через платёжного провайдера (сейчас — mock).
|
||||
-- Без RLS: запись — только серверные роуты (auth + billing.manage) и webhook провайдера,
|
||||
-- чтение — только через роуты с явным фильтром organization_id.
|
||||
CREATE TABLE IF NOT EXISTS billing_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
provider VARCHAR(30) NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
amount NUMERIC(10,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
idempotency_key VARCHAR(255) NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (external_id),
|
||||
UNIQUE (idempotency_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS billing_payments_org_status_idx
|
||||
ON billing_payments (organization_id, status);
|
||||
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);
|
||||
|
||||
@@ -87,6 +87,26 @@ export const billingTransactions = pgTable("billing_transactions", {
|
||||
export type BillingTransaction = typeof billingTransactions.$inferSelect;
|
||||
export type InsertBillingTransaction = typeof billingTransactions.$inferInsert;
|
||||
|
||||
// Billing payments (self-service top-ups via payment provider)
|
||||
export const billingPayments = pgTable("billing_payments", {
|
||||
id: serial("id").primaryKey(),
|
||||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
provider: varchar("provider", { length: 30 }).notNull(),
|
||||
externalId: varchar("external_id", { length: 255 }).notNull().unique("billing_payments_external_id_key"),
|
||||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||||
currency: varchar("currency", { length: 10 }).notNull().default("RUB"),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"), // 'pending' | 'succeeded' | 'canceled'
|
||||
idempotencyKey: varchar("idempotency_key", { length: 255 }).notNull().unique("billing_payments_idempotency_key_key"),
|
||||
metadata: jsonb("metadata"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
}, (table) => ({
|
||||
orgStatusIdx: index("billing_payments_org_status_idx").on(table.organizationId, table.status),
|
||||
}));
|
||||
|
||||
export type BillingPayment = typeof billingPayments.$inferSelect;
|
||||
export type InsertBillingPayment = typeof billingPayments.$inferInsert;
|
||||
|
||||
// Users table
|
||||
export const users = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
|
||||
360
tests/billing-payments.test.ts
Normal file
360
tests/billing-payments.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
getUserWithOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
// In-memory fake payments.service: воспроизводит контракт настоящего сервиса,
|
||||
// включая идемпотентность зачисления (pending→succeeded только один раз).
|
||||
const fake = vi.hoisted(() => {
|
||||
const state = {
|
||||
payments: new Map<number, any>(),
|
||||
nextId: 1,
|
||||
credits: [] as Array<{ organizationId: number; amount: string }>,
|
||||
};
|
||||
return { state };
|
||||
});
|
||||
|
||||
vi.mock('../server/db', () => ({
|
||||
db: {
|
||||
// Цепочка для loadPermissionCache в auth.middleware (requirePermission).
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
innerJoin: () => ({
|
||||
innerJoin: () => Promise.resolve([{ appRoleSlug: 'admin', permissionCode: 'billing.manage' }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
// Заглушка для trackUserActivity (best-effort обновление активности).
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
|
||||
withTenant: (_orgId: number, fn: () => unknown) => fn(),
|
||||
openTenantCtx: vi.fn().mockResolvedValue({
|
||||
run: (fn: () => void) => fn(),
|
||||
release: vi.fn(),
|
||||
}),
|
||||
openSuperAdminCtx: vi.fn().mockResolvedValue(undefined),
|
||||
_tenantCtx: { getStore: vi.fn().mockReturnValue(null) },
|
||||
}));
|
||||
|
||||
vi.mock('../server/storage', () => ({ storage: mockStorage }));
|
||||
|
||||
vi.mock('../server/services/notification.service', () => ({
|
||||
notificationService: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
processEvent: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
EVENT_TYPES: {},
|
||||
}));
|
||||
|
||||
vi.mock('../server/services/webhook.service', () => ({
|
||||
webhookService: {
|
||||
dispatchEvent: vi.fn().mockResolvedValue(undefined),
|
||||
processWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/webhook', () => ({
|
||||
sendWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/s3', () => ({
|
||||
isS3Enabled: false,
|
||||
ensureS3Bucket: vi.fn().mockResolvedValue(undefined),
|
||||
streamFromS3: vi.fn().mockResolvedValue(null),
|
||||
deleteFromS3: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/audit', () => ({
|
||||
logAudit: vi.fn().mockResolvedValue(undefined),
|
||||
getClientIp: vi.fn().mockReturnValue('127.0.0.1'),
|
||||
}));
|
||||
|
||||
vi.mock('../server/billing/payments.service', () => ({
|
||||
createBillingPayment: vi.fn(async (p: any) => {
|
||||
for (const row of fake.state.payments.values()) {
|
||||
if (row.idempotencyKey === p.idempotencyKey) return row;
|
||||
}
|
||||
const row = {
|
||||
id: fake.state.nextId++,
|
||||
status: 'pending',
|
||||
...p,
|
||||
amount: p.amount.toFixed(2),
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
fake.state.payments.set(row.id, row);
|
||||
return row;
|
||||
}),
|
||||
listBillingPayments: vi.fn(async (orgId: number) =>
|
||||
[...fake.state.payments.values()].filter((p) => p.organizationId === orgId)),
|
||||
getBillingPaymentById: vi.fn(async (id: number, orgId?: number) => {
|
||||
const p = fake.state.payments.get(id) ?? null;
|
||||
return p && (orgId === undefined || p.organizationId === orgId) ? p : null;
|
||||
}),
|
||||
getBillingPaymentByExternalId: vi.fn(async (externalId: string) =>
|
||||
[...fake.state.payments.values()].find((p) => p.externalId === externalId) ?? null),
|
||||
markBillingPaymentCanceled: vi.fn(async (id: number) => {
|
||||
const p = fake.state.payments.get(id) ?? null;
|
||||
if (p && p.status === 'pending') p.status = 'canceled';
|
||||
return p;
|
||||
}),
|
||||
applySucceededPayment: vi.fn(async (id: number) => {
|
||||
const p = fake.state.payments.get(id) ?? null;
|
||||
if (!p || p.status !== 'pending') return { applied: false, payment: p };
|
||||
p.status = 'succeeded';
|
||||
fake.state.credits.push({ organizationId: p.organizationId, amount: p.amount });
|
||||
return { applied: true, payment: p };
|
||||
}),
|
||||
}));
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import billingPaymentsRouter from '../server/routes/billing-payments.routes';
|
||||
import { signMockWebhook } from '../server/billing/mock-provider';
|
||||
|
||||
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET as string;
|
||||
|
||||
function makeValidToken(userId: number, organizationId: number): string {
|
||||
return jwt.sign(
|
||||
{ userId, organizationId, role: 'user' },
|
||||
ACCESS_SECRET,
|
||||
{ issuer: 'workflow-system', audience: 'workflow-users', expiresIn: '1h' },
|
||||
);
|
||||
}
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(billingPaymentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const TEST_USER = {
|
||||
id: 42,
|
||||
email: 'admin@example.com',
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
appRole: 'admin',
|
||||
role: 'user',
|
||||
isActive: true,
|
||||
organizationId: 7,
|
||||
organization: { id: 7, isActive: true, billingBlocked: true },
|
||||
};
|
||||
|
||||
const app = buildApp();
|
||||
|
||||
function authHeader(): [string, string] {
|
||||
return ['Authorization', `Bearer ${makeValidToken(TEST_USER.id, TEST_USER.organizationId)}`];
|
||||
}
|
||||
|
||||
async function createPayment(amount = 1500): Promise<any> {
|
||||
const res = await request(app)
|
||||
.post('/api/billing/payments')
|
||||
.set(...authHeader())
|
||||
.send({ amount });
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.payment;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fake.state.payments.clear();
|
||||
fake.state.nextId = 1;
|
||||
fake.state.credits.length = 0;
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER);
|
||||
delete process.env.PAYMENT_PROVIDER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.PAYMENT_PROVIDER;
|
||||
});
|
||||
|
||||
describe('POST /api/billing/payments', () => {
|
||||
it('создаёт платёж в статусе pending (201)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/billing/payments')
|
||||
.set(...authHeader())
|
||||
.send({ amount: 1500 });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.payment).toMatchObject({
|
||||
organizationId: TEST_USER.organizationId,
|
||||
provider: 'mock',
|
||||
status: 'pending',
|
||||
amount: '1500.00',
|
||||
currency: 'RUB',
|
||||
});
|
||||
expect(res.body.payment.externalId).toMatch(/^mock_/);
|
||||
});
|
||||
|
||||
it('возвращает 401 без токена', async () => {
|
||||
const res = await request(app).post('/api/billing/payments').send({ amount: 100 });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('возвращает 400 при некорректной сумме', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/billing/payments')
|
||||
.set(...authHeader())
|
||||
.send({ amount: -50 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('повторный запрос с тем же Idempotency-Key возвращает тот же платёж', async () => {
|
||||
const first = await request(app)
|
||||
.post('/api/billing/payments')
|
||||
.set(...authHeader())
|
||||
.set('Idempotency-Key', 'topup-abc-1')
|
||||
.send({ amount: 500 });
|
||||
const second = await request(app)
|
||||
.post('/api/billing/payments')
|
||||
.set(...authHeader())
|
||||
.set('Idempotency-Key', 'topup-abc-1')
|
||||
.send({ amount: 500 });
|
||||
|
||||
expect(first.status).toBe(201);
|
||||
expect(second.status).toBe(201);
|
||||
expect(second.body.payment.id).toBe(first.body.payment.id);
|
||||
expect(fake.state.payments.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/billing/payments', () => {
|
||||
it('возвращает платежи только своей организации', async () => {
|
||||
await createPayment(100);
|
||||
await createPayment(200);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/billing/payments')
|
||||
.set(...authHeader());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.payments).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/billing/payments/:id/confirm-test', () => {
|
||||
it('подтверждает платёж и зачисляет средства (снятие блокировки — внутри зачисления)', async () => {
|
||||
const payment = await createPayment(1500);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/billing/payments/${payment.id}/confirm-test`)
|
||||
.set(...authHeader());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.applied).toBe(true);
|
||||
expect(res.body.payment.status).toBe('succeeded');
|
||||
expect(fake.state.credits).toEqual([
|
||||
{ organizationId: TEST_USER.organizationId, amount: '1500.00' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('возвращает 404 для платежа чужой организации', async () => {
|
||||
const payment = await createPayment(100);
|
||||
const otherToken = makeValidToken(55, 999);
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue({
|
||||
...TEST_USER,
|
||||
id: 55,
|
||||
organizationId: 999,
|
||||
organization: { id: 999, isActive: true, billingBlocked: false },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/billing/payments/${payment.id}/confirm-test`)
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(fake.state.credits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('возвращает 404 при не-mock провайдере', async () => {
|
||||
const payment = await createPayment(100);
|
||||
process.env.PAYMENT_PROVIDER = 'yookassa';
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/billing/payments/${payment.id}/confirm-test`)
|
||||
.set(...authHeader());
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(fake.state.credits).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/billing/webhooks/:provider', () => {
|
||||
it('webhook с валидной подписью зачисляет платёж', async () => {
|
||||
const payment = await createPayment(1500);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/billing/webhooks/mock')
|
||||
.set('x-mock-signature', signMockWebhook(payment.externalId))
|
||||
.send({ externalId: payment.externalId, status: 'succeeded' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(fake.state.payments.get(payment.id)?.status).toBe('succeeded');
|
||||
expect(fake.state.credits).toEqual([
|
||||
{ organizationId: TEST_USER.organizationId, amount: '1500.00' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('повторный webhook не зачисляет дважды (идемпотентность)', async () => {
|
||||
const payment = await createPayment(1500);
|
||||
const signature = signMockWebhook(payment.externalId);
|
||||
const body = { externalId: payment.externalId, status: 'succeeded' };
|
||||
|
||||
const first = await request(app)
|
||||
.post('/api/billing/webhooks/mock')
|
||||
.set('x-mock-signature', signature)
|
||||
.send(body);
|
||||
const second = await request(app)
|
||||
.post('/api/billing/webhooks/mock')
|
||||
.set('x-mock-signature', signature)
|
||||
.send(body);
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(fake.state.credits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('webhook с невалидной подписью отклоняется (401)', async () => {
|
||||
const payment = await createPayment(1500);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/billing/webhooks/mock')
|
||||
.set('x-mock-signature', 'deadbeef'.repeat(8))
|
||||
.send({ externalId: payment.externalId, status: 'succeeded' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(fake.state.payments.get(payment.id)?.status).toBe('pending');
|
||||
expect(fake.state.credits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('webhook без подписи отклоняется (401)', async () => {
|
||||
const payment = await createPayment(1500);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/billing/webhooks/mock')
|
||||
.send({ externalId: payment.externalId, status: 'succeeded' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(fake.state.credits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('webhook для неизвестного провайдера возвращает 404', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/billing/webhooks/unknown-provider')
|
||||
.send({ externalId: 'mock_whatever', status: 'succeeded' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user