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;
mobile?: string | null;
email?: string | null;
idempotency_key: string;
}) {
return this.request<Types.CreatePaymentOut>(
'/api/payments/create',
@@ -1088,24 +1089,12 @@ class ApiClient {
}
async getPaymentByRef(refId: string) {
return this.request<{
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' });
return this.request<Types.PaymentDetailSchema>(`/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) {
return this.request<{
discount_amount: number;

View File

@@ -1063,9 +1063,31 @@ export interface BlogAnalyticsSchema {
// payment
export interface CreatePaymentOut {
start_pay_url: string;
authority: string;
start_pay_url: string | null;
authority: string | null;
payment_id: number | null;
idempotency_key: string | null;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
base_amount: number;
discount_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";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "@/lib/router";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
@@ -76,6 +76,8 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
const [submitting, setSubmitting] = useState(false);
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
const [nowTs, setNowTs] = useState(() => Date.now());
const checkoutInProgressRef = useRef(false);
const checkoutIdempotencyKeyRef = useRef<string | null>(null);
const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
@@ -163,6 +165,9 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
return;
}
if (checkoutInProgressRef.current) return;
checkoutInProgressRef.current = true;
try {
setSubmitting(true);
const reg = await api.registerForEvent(event.id, coupon);
@@ -185,9 +190,10 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
event_id: event.id,
description: `پرداخت رویداد: ${event.title}`,
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(
"payment:last",
buildPaymentSnapshot(event, eventThumb, {
@@ -200,6 +206,20 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
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(
"payment:last",
buildPaymentSnapshot(event, eventThumb, {
@@ -223,6 +243,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
});
} finally {
setSubmitting(false);
checkoutInProgressRef.current = false;
setOpen(false);
}
};

View File

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