feat(admin): show which domain a payment came from

Several storefronts share one gateway — the city booking sites and the
ClinicPro panel itself — so the payments report could not say where a
transaction originated. The payment already stores the return address it was
started with; the domain is derived from it.

Lowercased and stripped of "www." so one domain is one value in the report,
null when a payment has no return address. Exposed on both the list and the
detail (which also carries the full address), and the list search now matches
it, so filtering to a single site needs no new parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-19 22:38:27 +03:30
co-authored by Claude Opus 5
parent 60c2eaa35e
commit bb09316a75
7 changed files with 173 additions and 5 deletions
+6
View File
@@ -92,6 +92,12 @@ export default function PaymentDetailPage() {
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
<InfoRow
label="مبدأ"
value={payment.origin
? <span dir="ltr" title={payment.frontend_address ?? undefined}>{payment.origin}</span>
: null}
/>
<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)} />
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
const navigate = vi.fn();
vi.mock('react-router', async (orig) => ({
...(await orig<typeof import('react-router')>()),
useNavigate: () => navigate,
}));
vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} }));
import { api } from '../lib/api';
import PaymentsPage from './PaymentsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ROWS = [
{ uuid: 'pay-1', amount: 1500000, status: 'success', gateway: 'mellat', ref_id: '99123',
origin: 'nobat724.com', patient_mobile: '09120000001', appointment_uuid: null,
paid_at: '2026-08-19T10:00:00+00:00', created_at: '2026-08-19T09:55:00+00:00' },
// پرداخت‌های قدیمی آدرس بازگشت ندارند و مبدأشان ناشناخته است.
{ uuid: 'pay-2', amount: 900000, status: 'failed', gateway: 'mellat', ref_id: null,
origin: null, patient_mobile: '09120000002', appointment_uuid: null,
paid_at: null, created_at: '2026-08-18T09:00:00+00:00' },
];
beforeEach(() => {
navigate.mockReset();
get.mockReset();
get.mockResolvedValue({ success: true, data: ROWS, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 } });
});
describe('PaymentsPage (پرداخت‌های ادمین)', () => {
it('دامنهٔ مبدأ هر پرداخت را نشان می‌دهد', async () => {
renderWithProviders(<PaymentsPage />, { route: '/admin/payments' });
expect(await screen.findByText('nobat724.com')).toBeInTheDocument();
expect(screen.getByText('مبدأ')).toBeInTheDocument();
});
it('جستجو دامنه را هم به سرور می‌فرستد', async () => {
renderWithProviders(<PaymentsPage />, { route: '/admin/payments?search=nobat724.com' });
await waitFor(() =>
expect(get).toHaveBeenCalledWith(expect.stringContaining('search=nobat724.com')),
);
});
});
+8 -1
View File
@@ -75,6 +75,13 @@ export default function PaymentsPage() {
header: 'درگاه',
render: (p) => <span className="chip" style={{ fontSize: 12 }}>{p.gateway}</span>,
},
{
key: 'origin',
header: 'مبدأ',
render: (p) => p.origin
? <span className="chip" dir="ltr" style={{ fontSize: 12 }}>{p.origin}</span>
: <span className="muted"></span>,
},
{
key: 'ref_id',
header: 'شماره مرجع',
@@ -142,7 +149,7 @@ export default function PaymentsPage() {
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
placeholder="جستجو بر اساس موبایل یا شماره مرجع..."
placeholder="جستجو بر اساس موبایل، شماره مرجع یا دامنه..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
+4
View File
@@ -203,6 +203,10 @@ export interface Payment {
status: PaymentStatus;
gateway: PaymentGateway;
ref_id: string | null;
/** دامنهٔ مبدأ پرداخت (سایت نوبت‌دهی یا پنل)، از آدرس بازگشت استخراج می‌شود. */
origin: string | null;
/** آدرس بازگشتِ کامل؛ فقط در جزئیات می‌آید. */
frontend_address?: string | null;
card_pan?: string | null;
refunds?: { amount: number; ref: string; at: number }[];
patient_mobile: string;