feat(payment): add refund and reversal functionality to payment gateways
- Implemented `refund` and `reverse` methods in `PaymentGatewayInterface`. - Added `PaymentRefundResult` class to handle refund operation results. - Enhanced `MockGateway` and `SepGateway` to support refund and reversal operations. - Updated `PaymentManager` to include `refundPayment` and `reversePayment` methods for handling refunds and reversals in transactions. - Modified `ClinicSubscriptionRepository` and `SubscriptionService` to manage subscriptions during refunds. - Added admin API endpoints for processing refunds and reversals. - Updated security headers to allow form actions to the sandbox environment. - Documented the new refund and reversal features in the API documentation.
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Payment } from '../types';
|
||||
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
||||
import { formatDateTime, formatRial } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||
appointment: 'نوبت',
|
||||
@@ -35,6 +37,24 @@ export default function PaymentDetailPage() {
|
||||
});
|
||||
|
||||
const payment = data?.data;
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, setConfirm] = React.useState<null | 'refund' | 'reverse'>(null);
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: (kind: 'refund' | 'reverse') =>
|
||||
api.post<ApiResponse<{ status: string }>>(`/api/v1/admin/payments/${uuid}/${kind}`, {}),
|
||||
onSuccess: (_res, kind) => {
|
||||
toast.success(kind === 'refund' ? 'درخواست استرداد وجه ثبت شد' : 'برگشت وجه انجام شد');
|
||||
queryClient.invalidateQueries({ queryKey: ['payment', uuid] });
|
||||
setConfirm(null);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(e?.message || 'عملیات ناموفق بود');
|
||||
setConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const canRefund = payment?.status === 'success' && payment?.gateway === 'mellat';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -72,6 +92,7 @@ export default function PaymentDetailPage() {
|
||||
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
||||
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
||||
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
|
||||
<InfoRow label="شماره کارت" value={payment.card_pan ? <span dir="ltr" className="font-mono text-xs">{payment.card_pan}</span> : null} />
|
||||
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(payment.created_at)} />
|
||||
{payment.appointment_uuid && (
|
||||
@@ -86,6 +107,50 @@ export default function PaymentDetailPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payment.refunds && payment.refunds.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mt-4">
|
||||
<h3 className="text-sm font-semibold mb-3">استردادها</h3>
|
||||
{payment.refunds.map((r, i) => (
|
||||
<InfoRow
|
||||
key={i}
|
||||
label={formatDateTime(new Date(r.at * 1000).toISOString())}
|
||||
value={
|
||||
<span>
|
||||
{formatRial(r.amount)}
|
||||
{r.ref && <span dir="ltr" className="font-mono text-xs text-gray-400"> ({r.ref})</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canRefund && (
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button onClick={() => setConfirm('refund')} className="btn danger">
|
||||
استرداد وجه
|
||||
</button>
|
||||
<button onClick={() => setConfirm('reverse')} className="btn">
|
||||
برگشت وجه
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirm !== null}
|
||||
danger
|
||||
loading={action.isPending}
|
||||
title={confirm === 'reverse' ? 'برگشت وجه' : 'استرداد وجه'}
|
||||
message={
|
||||
confirm === 'reverse'
|
||||
? 'کل مبلغ این تراکنش برگشت داده میشود. ادامه میدهید؟'
|
||||
: 'درخواست استرداد کل مبلغ به کارت پرداختکننده ثبت میشود (عودت نهایی ممکن است چند روز طول بکشد). ادامه میدهید؟'
|
||||
}
|
||||
confirmLabel={confirm === 'reverse' ? 'برگشت وجه' : 'استرداد وجه'}
|
||||
onConfirm={() => confirm && action.mutate(confirm)}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface Payment {
|
||||
status: PaymentStatus;
|
||||
gateway: PaymentGateway;
|
||||
ref_id: string | null;
|
||||
card_pan?: string | null;
|
||||
refunds?: { amount: number; ref: string; at: number }[];
|
||||
patient_mobile: string;
|
||||
appointment_uuid: string | null;
|
||||
paid_at: string | null;
|
||||
|
||||
Reference in New Issue
Block a user