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:
@@ -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)} />
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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); }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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": "علی احمدی",
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user