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:
2026-09-07 22:45:30 +03:00
parent 23c49adcd1
commit 701af06290
11 changed files with 1039 additions and 14 deletions

View File

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