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={<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)} />
|
||||
|
||||
@@ -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: 'درگاه',
|
||||
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); }}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+9
-1
@@ -654,7 +654,7 @@ List all payments.
|
||||
### Query Parameters (تکمیل)
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id` یا `order_id` |
|
||||
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id`، `order_id` یا دامنهٔ مبدأ |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -667,6 +667,7 @@ List all payments.
|
||||
"status": "success",
|
||||
"gateway": "mellat",
|
||||
"ref_id": "1234567",
|
||||
"origin": "nobat724.com",
|
||||
"patient_mobile": "0912...",
|
||||
"paid_at": "2026-07-02T09:00: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.
|
||||
|
||||
> `origin` دامنهٔ مبدأ پرداخت است — از `frontend_address` (آدرس بازگشت) استخراج
|
||||
> میشود، با حروف کوچک و بدون `www.`، تا یک دامنه در گزارش یک مقدار باشد. چند مبدأ
|
||||
> به یک درگاه میروند: سایتهای نوبتدهی شهری و پنل خودِ کلینیکپرو. پرداخت بدون آدرس
|
||||
> بازگشت `null` میگیرد.
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/admin/payments/{uuid}`
|
||||
@@ -698,6 +704,8 @@ List all payments.
|
||||
"gateway": "mellat",
|
||||
"type": "appointment",
|
||||
"ref_id": "1234567",
|
||||
"origin": "nobat724.com",
|
||||
"frontend_address": "https://nobat724.com/payment/result",
|
||||
"card_pan": "502229******2928",
|
||||
"patient_mobile": "0912...",
|
||||
"patient_name": "علی احمدی",
|
||||
|
||||
@@ -1063,7 +1063,7 @@ class AdminApiController extends BaseController
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->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',
|
||||
)
|
||||
->from(Payment::class, 'p')
|
||||
@@ -1071,7 +1071,7 @@ class AdminApiController extends BaseController
|
||||
->orderBy('p.createdAt', 'DESC');
|
||||
|
||||
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 . '%');
|
||||
}
|
||||
if ($status !== '') {
|
||||
@@ -1089,6 +1089,7 @@ class AdminApiController extends BaseController
|
||||
'status' => $p['status'],
|
||||
'gateway' => $p['gateway'],
|
||||
'ref_id' => $p['referenceId'],
|
||||
'origin' => $this->paymentOrigin($p['frontendAddress']),
|
||||
'patient_mobile' => $p['patient_mobile'],
|
||||
'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null,
|
||||
'created_at' => date('c', (int) $p['createdAt']),
|
||||
@@ -1114,7 +1115,7 @@ class AdminApiController extends BaseController
|
||||
{
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->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',
|
||||
'a.uuid as appointment_uuid',
|
||||
)
|
||||
@@ -1137,6 +1138,8 @@ class AdminApiController extends BaseController
|
||||
'gateway' => $p['gateway'],
|
||||
'type' => $p['type'],
|
||||
'ref_id' => $p['referenceId'],
|
||||
'origin' => $this->paymentOrigin($p['frontendAddress']),
|
||||
'frontend_address' => $p['frontendAddress'],
|
||||
'card_pan' => $p['metadata']['card_pan'] ?? null,
|
||||
'refunds' => $p['metadata']['refunds'] ?? [],
|
||||
'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'])]
|
||||
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