diff --git a/assets/admin/pages/PaymentDetailPage.tsx b/assets/admin/pages/PaymentDetailPage.tsx index 7078adbb..4a96a0dd 100644 --- a/assets/admin/pages/PaymentDetailPage.tsx +++ b/assets/admin/pages/PaymentDetailPage.tsx @@ -92,6 +92,12 @@ export default function PaymentDetailPage() { } /> {payment.gateway}} /> + {payment.origin} + : null} + /> {payment.ref_id} : null} /> {payment.card_pan} : null} /> diff --git a/assets/admin/pages/PaymentsPage.test.tsx b/assets/admin/pages/PaymentsPage.test.tsx new file mode 100644 index 00000000..000f3b93 --- /dev/null +++ b/assets/admin/pages/PaymentsPage.test.tsx @@ -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()), + 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; + +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(, { route: '/admin/payments' }); + + expect(await screen.findByText('nobat724.com')).toBeInTheDocument(); + expect(screen.getByText('مبدأ')).toBeInTheDocument(); + }); + + it('جستجو دامنه را هم به سرور می‌فرستد', async () => { + renderWithProviders(, { route: '/admin/payments?search=nobat724.com' }); + + await waitFor(() => + expect(get).toHaveBeenCalledWith(expect.stringContaining('search=nobat724.com')), + ); + }); +}); diff --git a/assets/admin/pages/PaymentsPage.tsx b/assets/admin/pages/PaymentsPage.tsx index 66d22113..d1366cc3 100644 --- a/assets/admin/pages/PaymentsPage.tsx +++ b/assets/admin/pages/PaymentsPage.tsx @@ -75,6 +75,13 @@ export default function PaymentsPage() { header: 'درگاه', render: (p) => {p.gateway}, }, + { + key: 'origin', + header: 'مبدأ', + render: (p) => p.origin + ? {p.origin} + : , + }, { key: 'ref_id', header: 'شماره مرجع', @@ -142,7 +149,7 @@ export default function PaymentsPage() {
{ setSearch(e.target.value); setPage(1); }} /> diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index ad180f5e..abefc113 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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; diff --git a/docs/api/admin.md b/docs/api/admin.md index 22b76382..17db1c5f 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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": "علی احمدی", diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 8019cacc..8b8be1c4 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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 { diff --git a/tests/Payment/PaymentOriginTest.php b/tests/Payment/PaymentOriginTest.php new file mode 100644 index 00000000..88c047da --- /dev/null +++ b/tests/Payment/PaymentOriginTest.php @@ -0,0 +1,70 @@ +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); + } +}