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={formatRial(payment.amount)} />
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} /> <InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} /> <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.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={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.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: 'درگاه', header: 'درگاه',
render: (p) => <span className="chip" style={{ fontSize: 12 }}>{p.gateway}</span>, 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', key: 'ref_id',
header: 'شماره مرجع', header: 'شماره مرجع',
@@ -142,7 +149,7 @@ export default function PaymentsPage() {
<div className="field" style={{ minWidth: 240 }}> <div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} /> <MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input <input
placeholder="جستجو بر اساس موبایل یا شماره مرجع..." placeholder="جستجو بر اساس موبایل، شماره مرجع یا دامنه..."
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/> />
+4
View File
@@ -203,6 +203,10 @@ export interface Payment {
status: PaymentStatus; status: PaymentStatus;
gateway: PaymentGateway; gateway: PaymentGateway;
ref_id: string | null; ref_id: string | null;
/** دامنهٔ مبدأ پرداخت (سایت نوبت‌دهی یا پنل)، از آدرس بازگشت استخراج می‌شود. */
origin: string | null;
/** آدرس بازگشتِ کامل؛ فقط در جزئیات می‌آید. */
frontend_address?: string | null;
card_pan?: string | null; card_pan?: string | null;
refunds?: { amount: number; ref: string; at: number }[]; refunds?: { amount: number; ref: string; at: number }[];
patient_mobile: string; patient_mobile: string;
+9 -1
View File
@@ -654,7 +654,7 @@ List all payments.
### Query Parameters (تکمیل) ### Query Parameters (تکمیل)
| Param | Type | Required | Description | | Param | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id` یا `order_id` | | `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id`، `order_id` یا دامنهٔ مبدأ |
### Response `200` ### Response `200`
```json ```json
@@ -667,6 +667,7 @@ List all payments.
"status": "success", "status": "success",
"gateway": "mellat", "gateway": "mellat",
"ref_id": "1234567", "ref_id": "1234567",
"origin": "nobat724.com",
"patient_mobile": "0912...", "patient_mobile": "0912...",
"paid_at": "2026-07-02T09:00:00+03:30", "paid_at": "2026-07-02T09:00:00+03:30",
"created_at": "2026-07-02T08:55:00+03:30" "created_at": "2026-07-02T08:55:00+03:30"
@@ -678,6 +679,11 @@ List all payments.
> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر این‌صورت `null`. تاریخ‌ها ISO-8601. > `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر این‌صورت `null`. تاریخ‌ها ISO-8601.
> `origin` دامنهٔ مبدأ پرداخت است — از `frontend_address` (آدرس بازگشت) استخراج
> می‌شود، با حروف کوچک و بدون `www.`، تا یک دامنه در گزارش یک مقدار باشد. چند مبدأ
> به یک درگاه می‌روند: سایت‌های نوبت‌دهی شهری و پنل خودِ کلینیک‌پرو. پرداخت بدون آدرس
> بازگشت `null` می‌گیرد.
--- ---
### GET `/api/v1/admin/payments/{uuid}` ### GET `/api/v1/admin/payments/{uuid}`
@@ -698,6 +704,8 @@ List all payments.
"gateway": "mellat", "gateway": "mellat",
"type": "appointment", "type": "appointment",
"ref_id": "1234567", "ref_id": "1234567",
"origin": "nobat724.com",
"frontend_address": "https://nobat724.com/payment/result",
"card_pan": "502229******2928", "card_pan": "502229******2928",
"patient_mobile": "0912...", "patient_mobile": "0912...",
"patient_name": "علی احمدی", "patient_name": "علی احمدی",
+28 -3
View File
@@ -1063,7 +1063,7 @@ class AdminApiController extends BaseController
$qb = $this->em->createQueryBuilder() $qb = $this->em->createQueryBuilder()
->select( ->select(
'p.uuid, p.amountRials, p.status, p.gateway, p.referenceId, p.createdAt, p.updatedAt', 'p.uuid, p.amountRials, p.status, p.gateway, p.referenceId, p.frontendAddress, p.createdAt, p.updatedAt',
'u.mobileNumber as patient_mobile', 'u.mobileNumber as patient_mobile',
) )
->from(Payment::class, 'p') ->from(Payment::class, 'p')
@@ -1071,7 +1071,7 @@ class AdminApiController extends BaseController
->orderBy('p.createdAt', 'DESC'); ->orderBy('p.createdAt', 'DESC');
if ($search !== '') { if ($search !== '') {
$qb->andWhere('u.mobileNumber LIKE :s OR p.referenceId LIKE :s OR p.orderId LIKE :s') $qb->andWhere('u.mobileNumber LIKE :s OR p.referenceId LIKE :s OR p.orderId LIKE :s OR p.frontendAddress LIKE :s')
->setParameter('s', '%' . $search . '%'); ->setParameter('s', '%' . $search . '%');
} }
if ($status !== '') { if ($status !== '') {
@@ -1089,6 +1089,7 @@ class AdminApiController extends BaseController
'status' => $p['status'], 'status' => $p['status'],
'gateway' => $p['gateway'], 'gateway' => $p['gateway'],
'ref_id' => $p['referenceId'], 'ref_id' => $p['referenceId'],
'origin' => $this->paymentOrigin($p['frontendAddress']),
'patient_mobile' => $p['patient_mobile'], 'patient_mobile' => $p['patient_mobile'],
'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null, 'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null,
'created_at' => date('c', (int) $p['createdAt']), 'created_at' => date('c', (int) $p['createdAt']),
@@ -1114,7 +1115,7 @@ class AdminApiController extends BaseController
{ {
$rows = $this->em->createQueryBuilder() $rows = $this->em->createQueryBuilder()
->select( ->select(
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.metadata, p.createdAt, p.updatedAt', 'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.metadata, p.frontendAddress, p.createdAt, p.updatedAt',
'u.mobileNumber as patient_mobile, u.realName as patient_name', 'u.mobileNumber as patient_mobile, u.realName as patient_name',
'a.uuid as appointment_uuid', 'a.uuid as appointment_uuid',
) )
@@ -1137,6 +1138,8 @@ class AdminApiController extends BaseController
'gateway' => $p['gateway'], 'gateway' => $p['gateway'],
'type' => $p['type'], 'type' => $p['type'],
'ref_id' => $p['referenceId'], 'ref_id' => $p['referenceId'],
'origin' => $this->paymentOrigin($p['frontendAddress']),
'frontend_address' => $p['frontendAddress'],
'card_pan' => $p['metadata']['card_pan'] ?? null, 'card_pan' => $p['metadata']['card_pan'] ?? null,
'refunds' => $p['metadata']['refunds'] ?? [], 'refunds' => $p['metadata']['refunds'] ?? [],
'patient_mobile' => $p['patient_mobile'], 'patient_mobile' => $p['patient_mobile'],
@@ -1147,6 +1150,28 @@ class AdminApiController extends BaseController
]); ]);
} }
/**
* دامنهٔ مبدأ پرداخت، از آدرس بازگشتِ ثبت‌شده روی خودِ پرداخت.
*
* محصول چند مبدأ دارد (سایت‌های نوبت‌دهی شهری، پنل خودِ کلینیک‌پرو) و همه به یک
* درگاه می‌روند؛ بدون این، گزارش پرداخت‌ها نمی‌گوید تراکنش از کدام سایت آمده.
*
* `www.` حذف می‌شود تا دو نوشتنِ یک دامنه در گزارش دو سطر جدا نشوند.
*/
private function paymentOrigin(?string $frontendAddress): ?string
{
if ($frontendAddress === null || trim($frontendAddress) === '') {
return null;
}
$host = parse_url(trim($frontendAddress), PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return null;
}
return preg_replace('/^www\./i', '', strtolower($host));
}
#[Route('/api/v1/admin/payments/{uuid}/refund', methods: ['POST'])] #[Route('/api/v1/admin/payments/{uuid}/refund', methods: ['POST'])]
public function refundPayment(string $uuid, Request $request): JsonResponse public function refundPayment(string $uuid, Request $request): JsonResponse
{ {
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Tests\Payment;
use App\Payment\Entity\Payment;
use App\Tests\ApiTestCase;
/**
* Several storefronts (city booking sites, the ClinicPro panel) share one
* gateway, so the payments report has to say which one a transaction came
* from. The origin is derived from the return address stored on the payment.
*/
class PaymentOriginTest extends ApiTestCase
{
private function makePayment(string $frontendAddress): Payment
{
$payment = new Payment($this->createUser(), 100_000, 'mellat', Payment::TYPE_SUBSCRIPTION, $frontendAddress);
$this->stampTenant($payment);
$this->em->persist($payment);
$this->em->flush();
return $payment;
}
private function admin(): \App\Auth\Entity\User
{
return $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
}
public function testListExposesTheOriginDomain(): void
{
$payment = $this->makePayment('https://nobat724.com/payment/result');
$body = $this->authJson('GET', '/api/v1/admin/payments?limit=100', $this->admin());
$row = current(array_filter($body['data'], fn(array $r) => $r['uuid'] === $payment->getUuid()));
self::assertSame('nobat724.com', $row['origin']);
}
public function testWwwAndCaseAreNormalisedSoOneDomainIsOneValue(): void
{
$payment = $this->makePayment('https://WWW.Nobat724.com/payment/result');
$body = $this->authJson('GET', '/api/v1/admin/payments/' . $payment->getUuid(), $this->admin());
self::assertSame('nobat724.com', $body['data']['origin']);
self::assertSame('https://WWW.Nobat724.com/payment/result', $body['data']['frontend_address']);
}
public function testPaymentWithoutAReturnAddressHasNoOrigin(): void
{
$payment = $this->makePayment('');
$body = $this->authJson('GET', '/api/v1/admin/payments/' . $payment->getUuid(), $this->admin());
self::assertNull($body['data']['origin']);
}
public function testSearchMatchesTheOriginDomain(): void
{
$wanted = $this->makePayment('https://yasuj-nobat.ir/payment/result');
$other = $this->makePayment('https://nobat724.com/payment/result');
$body = $this->authJson('GET', '/api/v1/admin/payments?limit=100&search=yasuj-nobat.ir', $this->admin());
$uuids = array_column($body['data'], 'uuid');
self::assertContains($wanted->getUuid(), $uuids);
self::assertNotContains($other->getUuid(), $uuids);
}
}