;
+
+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);
+ }
+}