fix(payments): reuse checkout attempts and show pending status

This commit is contained in:
2026-09-30 21:14:19 +03:30
parent 450b387228
commit 9f3f3631ac
4 changed files with 191 additions and 67 deletions

View File

@@ -1076,6 +1076,7 @@ class ApiClient {
discount_code?: string | null; discount_code?: string | null;
mobile?: string | null; mobile?: string | null;
email?: string | null; email?: string | null;
idempotency_key: string;
}) { }) {
return this.request<Types.CreatePaymentOut>( return this.request<Types.CreatePaymentOut>(
'/api/payments/create', '/api/payments/create',
@@ -1088,24 +1089,12 @@ class ApiClient {
} }
async getPaymentByRef(refId: string) { async getPaymentByRef(refId: string) {
return this.request<{ return this.request<Types.PaymentDetailSchema>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' });
ref_id: string;
authority: string;
base_amount: number;
discount_amount: number;
amount: number;
status: 'INIT' | 'PENDING' | 'PAID' | 'FAILED' | 'CANCELED';
verified_at?: string | null;
event: {
id: number;
title: string;
slug: string;
image_url?: string | null;
success_markdown?: string | null;
};
}>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' });
} }
async getPaymentStatus(paymentId: number) {
return this.request<Types.PaymentDetailSchema>(`/api/payments/status/${paymentId}`, { method: 'GET' });
}
async checkDiscountCode(event_id: number, code: string) { async checkDiscountCode(event_id: number, code: string) {
return this.request<{ return this.request<{
discount_amount: number; discount_amount: number;

View File

@@ -1063,9 +1063,31 @@ export interface BlogAnalyticsSchema {
// payment // payment
export interface CreatePaymentOut { export interface CreatePaymentOut {
start_pay_url: string; start_pay_url: string | null;
authority: string; authority: string | null;
payment_id: number | null;
idempotency_key: string | null;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
base_amount: number; base_amount: number;
discount_amount: number; discount_amount: number;
amount: number; amount: number;
} }
export interface PaymentDetailSchema {
payment_id: number;
event_id: number;
ref_id: string | null;
authority: string | null;
base_amount: number;
discount_amount: number;
amount: number;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
verified_at: string | null;
event: {
id: number;
title: string;
slug: string;
image_url?: string | null;
success_markdown?: string | null;
};
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "@/lib/router"; import { useNavigate, useParams } from "@/lib/router";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type * as Types from "@/lib/types"; import type * as Types from "@/lib/types";
@@ -76,6 +76,8 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [alreadyRegistered, setAlreadyRegistered] = useState(false); const [alreadyRegistered, setAlreadyRegistered] = useState(false);
const [nowTs, setNowTs] = useState(() => Date.now()); const [nowTs, setNowTs] = useState(() => Date.now());
const checkoutInProgressRef = useRef(false);
const checkoutIdempotencyKeyRef = useRef<string | null>(null);
const basePrice = Number(event?.price ?? 0); const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]); const isFree = useMemo(() => basePrice <= 0, [basePrice]);
@@ -163,6 +165,9 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
return; return;
} }
if (checkoutInProgressRef.current) return;
checkoutInProgressRef.current = true;
try { try {
setSubmitting(true); setSubmitting(true);
const reg = await api.registerForEvent(event.id, coupon); const reg = await api.registerForEvent(event.id, coupon);
@@ -185,9 +190,10 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
event_id: event.id, event_id: event.id,
description: `پرداخت رویداد: ${event.title}`, description: `پرداخت رویداد: ${event.title}`,
discount_code: (coupon ?? "").trim() || null, discount_code: (coupon ?? "").trim() || null,
idempotency_key: checkoutIdempotencyKeyRef.current ??= crypto.randomUUID(),
}); });
if (!result?.start_pay_url || Number(result.amount) === 0) { if (Number(result?.amount ?? 0) === 0) {
window.sessionStorage.setItem( window.sessionStorage.setItem(
"payment:last", "payment:last",
buildPaymentSnapshot(event, eventThumb, { buildPaymentSnapshot(event, eventThumb, {
@@ -200,6 +206,20 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
return; return;
} }
if (result.status === "failed" || result.status === "canceled") {
checkoutIdempotencyKeyRef.current = null;
setOpen(false);
navigate(`/payments/result?status=${result.status}&event_id=${event.id}&payment_id=${result.payment_id ?? ""}`);
return;
}
if (!result.start_pay_url) {
setOpen(false);
const resultStatus = result.status === "paid" ? "success" : "pending";
navigate(`/payments/result?status=${resultStatus}&event_id=${event.id}&payment_id=${result.payment_id ?? ""}`);
return;
}
window.sessionStorage.setItem( window.sessionStorage.setItem(
"payment:last", "payment:last",
buildPaymentSnapshot(event, eventThumb, { buildPaymentSnapshot(event, eventThumb, {
@@ -223,6 +243,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
}); });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
checkoutInProgressRef.current = false;
setOpen(false); setOpen(false);
} }
}; };

View File

@@ -12,8 +12,10 @@ import { api } from '@/lib/api';
import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils'; import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils';
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';
import { siteUrl } from '@/lib/site'; import { siteUrl } from '@/lib/site';
import { getEventPath, getPaymentOutcome, type PaymentOutcome } from '@/lib/event-ui';
type SavedPayment = { type SavedPayment = {
ref_id?: string | null;
event_id: number; event_id: number;
slug?: string; slug?: string;
title?: string; title?: string;
@@ -23,45 +25,126 @@ type SavedPayment = {
amount?: number; amount?: number;
started_at?: string; started_at?: string;
success_markdown?: string | null; success_markdown?: string | null;
status?: string;
}; };
export default function PaymentResult() { export default function PaymentResult() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const status = params.get('status'); // success | failed const status = params.get('status');
const refId = params.get('ref_id') || ''; const requestedRefId = params.get('ref_id') || '';
const eventId = Number(params.get('event_id') || '0'); const paymentId = Number(params.get('payment_id') || '0');
const [fallback, setFallback] = useState<SavedPayment | null>(null);
const [manualReview, setManualReview] = useState(false);
const refId = requestedRefId || fallback?.ref_id || '';
const eventId = Number(params.get('event_id') || fallback?.event_id || '0');
const humanEventId = eventId ? formatNumberPersian(eventId) : '—'; const humanEventId = eventId ? formatNumberPersian(eventId) : '—';
const refIdDisplay = refId ? toPersianDigits(refId) : ''; const refIdDisplay = refId ? toPersianDigits(refId) : '';
const [fallback, setFallback] = useState<SavedPayment | null>(null);
// 2) اگر saved نبود و refId هست، از بک‌اند اطلاعات را می‌گیریم (اندپوینت اختیاری by-ref)
useEffect(() => { useEffect(() => {
(async () => { let cancelled = false;
if (!refId) return; let timer: number | undefined;
try { let delay = 5_000;
const p = await api.getPaymentByRef(refId); const pollingDeadline = Date.now() + 35 * 60 * 1_000;
const updatePayment = (payment: Awaited<ReturnType<typeof api.getPaymentStatus>>) => {
setFallback({ setFallback({
event_id: p.event.id, ref_id: payment.ref_id,
slug: p.event.slug, event_id: payment.event.id,
title: p.event.title, slug: payment.event.slug,
thumb: p.event.image_url || null, title: payment.event.title,
base_amount: p.base_amount, thumb: payment.event.image_url || null,
discount_amount: p.discount_amount, base_amount: payment.base_amount,
amount: p.amount, discount_amount: payment.discount_amount,
started_at: p.verified_at || undefined, amount: payment.amount,
success_markdown: p.event?.success_markdown started_at: payment.verified_at || undefined,
success_markdown: payment.event.success_markdown,
status: payment.status,
}); });
} catch {
// بی‌صدا؛ حداقل status/ref_id نمایش داده می‌شود const pending = getPaymentOutcome(payment.status) === 'pending';
const canVerifyWithGateway = Boolean(payment.authority);
if (pending && !canVerifyWithGateway && payment.status !== 'initiated') {
setManualReview(true);
} else if (pending && Date.now() >= pollingDeadline) {
setManualReview(true);
} else {
setManualReview(false);
} }
})(); };
}, [refId]);
const refreshPayment = async () => {
try {
const payment = paymentId
? await api.getPaymentStatus(paymentId)
: requestedRefId
? await api.getPaymentByRef(requestedRefId)
: null;
if (!payment || cancelled) return;
updatePayment(payment);
if (
paymentId &&
getPaymentOutcome(payment.status) === 'pending' &&
(Boolean(payment.authority) || payment.status === 'initiated') &&
Date.now() < pollingDeadline
) {
timer = window.setTimeout(refreshPayment, delay);
delay = Math.min(delay * 2, 30_000);
}
} catch {
if (paymentId && !cancelled && Date.now() < pollingDeadline) {
timer = window.setTimeout(refreshPayment, delay);
delay = Math.min(delay * 2, 30_000);
} else if (paymentId && !cancelled) {
setManualReview(true);
}
}
};
void refreshPayment();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [paymentId, requestedRefId]);
const data = fallback; const data = fallback;
const ok = status === 'success'; const outcome: PaymentOutcome = fallback?.status
? getPaymentOutcome(fallback.status)
: getPaymentOutcome(status);
const ok = outcome === 'paid';
const outcomeContent: Record<PaymentOutcome, { title: string; subtitle: string; pageState: string }> = {
paid: {
title: 'پرداخت تأیید شد',
subtitle: 'درگاه پرداخت، تراکنش را تأیید کرده است و ثبت‌نام رویداد تکمیل شده است.',
pageState: 'پرداخت تأیید شد',
},
pending: {
title: 'وضعیت پرداخت هنوز نهایی نیست',
subtitle: manualReview
? 'وضعیت پرداخت هنوز قطعی نیست و برای بررسی پشتیبانی در انتظار مانده است. ثبت‌نام را نهایی‌شده تلقی نکنید.'
: 'وضعیت پرداخت هنوز نهایی نیست. تا دریافت تأیید قطعی، ثبت‌نام را نهایی‌شده تلقی نکنید.',
pageState: 'پرداخت در انتظار تأیید',
},
failed: {
title: 'پرداخت تأیید نشد',
subtitle: 'درگاه پرداخت این تراکنش را تأیید نکرده است؛ ثبت‌نام نهایی نشده است. می‌توانید از صفحهٔ رویداد دوباره اقدام کنید.',
pageState: 'پرداخت ناموفق',
},
canceled: {
title: 'پرداخت لغو شد',
subtitle: 'پرداخت کامل نشد و ثبت‌نام نهایی نشده است. برای شروع دوباره به صفحهٔ رویداد برگردید.',
pageState: 'پرداخت لغوشده',
},
unknown: {
title: 'وضعیت پرداخت مشخص نیست',
subtitle: 'وضعیت قطعی تراکنش در دسترس نیست. تا دریافت تأیید سامانه، ثبت‌نام را نهایی‌شده تلقی نکنید.',
pageState: 'وضعیت پرداخت نامشخص',
},
};
const currentOutcome = outcomeContent[outcome];
const money = (n?: number) => (typeof n === 'number' && Number.isFinite(n) ? formatToman(n) : '—'); const money = (n?: number) => (typeof n === 'number' && Number.isFinite(n) ? formatToman(n) : '—');
const receiptRef = useRef<HTMLDivElement | null>(null); const receiptRef = useRef<HTMLDivElement | null>(null);
const successMarkdown = data?.success_markdown ?? ''; const successMarkdown = data?.success_markdown ?? '';
@@ -77,14 +160,8 @@ export default function PaymentResult() {
}; };
const eventTitle = data?.title || (eventId ? `Event #${humanEventId}` : 'Event payment'); const eventTitle = data?.title || (eventId ? `Event #${humanEventId}` : 'Event payment');
const referenceFragment = refId ? ` Reference: ${refIdDisplay}.` : ''; const referenceFragment = refId ? ` Reference: ${refIdDisplay}.` : '';
const pageState = const pageTitle = `${currentOutcome.pageState} | ${siteName}`;
status === 'success' const pageDescription = `${currentOutcome.subtitle} ${eventTitle}.${referenceFragment}`;
? 'Payment successful'
: status === 'failed'
? 'Payment failed'
: 'Payment status';
const pageTitle = `${pageState} | ${siteName}`;
const pageDescription = `${ok ? 'Payment confirmed' : 'Review your payment status'} for ${eventTitle}.${referenceFragment}`;
const ogImage = toAbsoluteUrl(data?.thumb) ?? `${siteUrl}/favicon.ico`; const ogImage = toAbsoluteUrl(data?.thumb) ?? `${siteUrl}/favicon.ico`;
const helmet = ( const helmet = (
<Helmet> <Helmet>
@@ -118,9 +195,17 @@ export default function PaymentResult() {
const url = new URL(`${base}/payments/result`); const url = new URL(`${base}/payments/result`);
if (refId) url.searchParams.set('ref_id', refId); if (refId) url.searchParams.set('ref_id', refId);
if (eventId) url.searchParams.set('event_id', String(eventId)); if (eventId) url.searchParams.set('event_id', String(eventId));
url.searchParams.set('status', ok ? 'success' : 'failed'); url.searchParams.set('status', outcome);
return url.toString(); return url.toString();
}, [eventId, ok, refId]); }, [eventId, outcome, refId]);
const outcomeStyle = {
paid: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100',
pending: 'border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-100',
failed: 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-900/30 dark:text-red-100',
canceled: 'border-border bg-muted/50 text-foreground',
unknown: 'border-border bg-muted/50 text-foreground',
}[outcome];
const handleDownloadPdf = async () => { const handleDownloadPdf = async () => {
const el = receiptRef.current; const el = receiptRef.current;
@@ -178,13 +263,12 @@ export default function PaymentResult() {
{/* Header (status + ref) */} {/* Header (status + ref) */}
<div <div
className={[ className={[
"rounded-md p-3 text-sm mb-4 border", "mb-4 rounded-md border p-3 text-sm",
ok outcomeStyle,
? "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-200 dark:border-emerald-800/50"
: "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-200 dark:border-red-800/50",
].join(" ")} ].join(" ")}
> >
{ok ? "پرداخت با موفقیت انجام شد." : "پرداخت ناموفق بود."} <p className="font-semibold">{currentOutcome.title}</p>
<p className="mt-1 leading-6">{currentOutcome.subtitle}</p>
</div> </div>
{/* Event + QR */} {/* Event + QR */}
@@ -230,9 +314,11 @@ export default function PaymentResult() {
</div> </div>
</div> </div>
{ok && successMarkdown ? (
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2"> <div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
<Markdown content={successMarkdown} justify size="base" /> <Markdown content={successMarkdown} justify size="base" />
</div> </div>
) : null}
{/* Invoice */} {/* Invoice */}
<div className="mt-6 rounded-md border border-border p-3"> <div className="mt-6 rounded-md border border-border p-3">
@@ -246,6 +332,10 @@ export default function PaymentResult() {
<span className="text-muted-foreground">تخفیف</span> <span className="text-muted-foreground">تخفیف</span>
<span>{money(data?.discount_amount)}</span> <span>{money(data?.discount_amount)}</span>
</li> </li>
<li className="flex items-center justify-between py-2">
<span className="text-muted-foreground">وضعیت</span>
<span>{currentOutcome.title}</span>
</li>
<li className="flex items-center justify-between py-2 font-semibold"> <li className="flex items-center justify-between py-2 font-semibold">
<span>مبلغ نهایی</span> <span>مبلغ نهایی</span>
<span>{money(data?.amount)}</span> <span>{money(data?.amount)}</span>
@@ -256,9 +346,11 @@ export default function PaymentResult() {
{/* Actions */} {/* Actions */}
<div className="mt-4 flex flex-wrap gap-2 justify-end print:hidden"> <div className="mt-4 flex flex-wrap gap-2 justify-end print:hidden">
{data?.slug ? ( {data?.event_id || eventId ? (
<Link to={`/events/${data.slug}`}> <Link to={getEventPath({ id: data?.event_id || eventId, slug: data?.slug })}>
<Button variant="outline">بازگشت به رویداد</Button> <Button variant="outline">
{ok ? "مشاهده رویداد" : outcome === "pending" ? "بازگشت به رویداد" : "بازگشت برای اقدام دوباره"}
</Button>
</Link> </Link>
) : ( ) : (
<Link to="/events"> <Link to="/events">