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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user