From 5c8fe8ece49579082b1fd9138444af08c39c50ea Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 10:12:01 +0330 Subject: [PATCH] feat: add payments summary endpoint and UI redesign for MyPaymentsPage - Implemented a new API endpoint `/api/v1/my/billing/payments/summary` to provide a financial summary of payments with filters for national code, status, and date range. - Updated the InvoiceRepository to aggregate totals for paid and unsettled invoices. - Created a new hook `usePaymentsSummary` to fetch summary data in the frontend. - Redesigned the MyPaymentsPage to align with the ClaimsPage structure, incorporating a design system, summary statistics, and improved filtering options. - Added tests for the new payments summary endpoint to ensure correct functionality and filtering behavior. --- .claude/prompt/my-payments-ui-redesign.md | 294 +++++++++++++++++++ assets/admin/components/ui/StatusBadge.tsx | 12 +- assets/admin/hooks/useMyPayments.ts | 21 ++ assets/admin/pages/MyPaymentsPage.test.tsx | 46 ++- assets/admin/pages/MyPaymentsPage.tsx | 239 ++++++++------- assets/admin/types/index.ts | 3 + docs/api/billing.md | 21 ++ src/Billing/Controller/BillingController.php | 46 ++- src/Billing/Repository/InvoiceRepository.php | 30 ++ src/Billing/Service/InvoiceService.php | 12 + tests/Billing/PaymentsSummaryTest.php | 140 +++++++++ 11 files changed, 735 insertions(+), 129 deletions(-) create mode 100644 .claude/prompt/my-payments-ui-redesign.md create mode 100644 tests/Billing/PaymentsSummaryTest.php diff --git a/.claude/prompt/my-payments-ui-redesign.md b/.claude/prompt/my-payments-ui-redesign.md new file mode 100644 index 00000000..6e187cd7 --- /dev/null +++ b/.claude/prompt/my-payments-ui-redesign.md @@ -0,0 +1,294 @@ +# بازطراحی UI/UX صفحه «لیست پرداخت‌ها» (`/admin/my-payments`) + +## پروژه + +`clinicpro` — پنل ادمین React (`assets/admin/`) + یک اندپوینت خلاصه در بک‌اند Symfony (`src/Billing/`). + +## زمینه + +صفحه‌ی `/admin/my-payments` ([MyPaymentsPage.tsx](clinicpro/assets/admin/pages/MyPaymentsPage.tsx)) با inline-styleهای دستی و یک `` خام نوشته شده و از design-system پروژه استفاده نمی‌کند. در همان پنل، صفحه‌ی `/admin/claims` ([ClaimsPage.tsx](clinicpro/assets/admin/pages/ClaimsPage.tsx)) الگوی درست و پخته‌ی یک صفحه‌ی لیست است: `PageHeader` با breadcrumb، ردیف `StatCard`، کارت فیلترها با `field-label`، میان‌برهای بازه‌ی زمانی، `DataTable` (سورت + جستجو + skeleton + empty state) و `Pagination`. هدف: هم‌سطح‌کردن `my-payments` با همان الگو. + +## مشکل + +وضعیت فعلی صفحه: + +1. **بدون design-system** — جدول خام با `th`/`td` inline style به‌جای `DataTable`. یعنی: بدون skeleton loading، بدون سورت، بدون empty state استاندارد. +2. **بدون هیچ آمار خلاصه‌ای** — کاربر هیچ دید کلی از مجموع مبلغ/تعداد/تسویه‌نشده ندارد (بر خلاف claims که ۴ `StatCard` دارد). +3. **ستون `status` نمایش داده نمی‌شود** — با اینکه `PaymentRow.status` (`paid | unsettled`) از API می‌آید و فیلترش هم در UI هست، در جدول هیچ ستون وضعیتی وجود ندارد. کاربر فیلتر می‌کند ولی نتیجه‌اش را نمی‌بیند. +4. **فیلترها بدون label و بدون کارت** — یک ردیف شناور بالای صفحه، بدون `field-label`، بدون دکمه‌ی «پاک‌کردن فیلترها»، بدون میان‌بر «یک ماه اخیر / یک سال اخیر». +5. **فیلترها در state محلی‌اند، نه در query string** — رفرش صفحه یا اشتراک لینک، فیلترها و شماره‌ی صفحه را از بین می‌برد. `ClaimsPage` این را با `useSearchParams` حل کرده. +6. **جستجو فقط کد ملی است** — با `input` دست‌ساز، در حالی که `DataTable` خودش `searchValue`/`onSearchChange` دارد. +7. **`PersianDateInput` به‌جای `PersianDatePicker`** — ناهماهنگ با claims و بدون `height={38}` هم‌تراز با `SearchableSelect`. +8. **action هدر بی‌ربط است** — دکمه‌ی «اضافه کردن بیمار» در صفحه‌ی پرداخت‌ها منطق ندارد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/assets/admin/pages/MyPaymentsPage.tsx` | صفحه‌ای که بازنویسی می‌شود | +| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | **الگوی مرجع** — ساختار را از این کپی کن | +| `clinicpro/assets/admin/hooks/useMyPayments.ts` | `usePayments`، `PaymentRow`، `MY_PAYMENTS_LIMIT` — hook خلاصه اینجا اضافه می‌شود | +| `clinicpro/assets/admin/components/ui/DataTable.tsx` | جدول design-system | +| `clinicpro/assets/admin/components/ui/StatCard.tsx` | کارت آمار (`tone: amber\|violet\|green\|pink`) | +| `clinicpro/assets/admin/components/ui/StatusBadge.tsx` | بج وضعیت — نیاز به type جدید `invoice` | +| `clinicpro/assets/admin/components/ui/PersianDatePicker.tsx` | انتخاب تاریخ هم‌راستا با claims | +| `clinicpro/assets/admin/types/index.ts` | تعریف `InvoiceListStatus` | +| `clinicpro/src/Billing/Controller/BillingController.php` | اندپوینت `listPayments` (L163) — اندپوینت خلاصه کنارش | +| `clinicpro/src/Billing/Service/InvoiceService.php` | `tenantInvoiceList` — متد خلاصه کنارش | +| `clinicpro/docs/api/billing.md` | مستند API (Standing Rule) | +| `clinicpro/assets/admin/pages/MyPaymentsPage.test.tsx` | تست‌های موجود — باید به‌روز شوند | + +## وضعیت فعلی + +`MyPaymentsPage.tsx` (خلاصه‌ی بخش‌های مشکل‌دار): + +```tsx +const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 }; +const td: React.CSSProperties = { padding: '12px 16px' }; + +const [page, setPage] = useState(1); +const [nationalCode, setNationalCode] = useState(''); +const [status, setStatus] = useState(''); +const [from, setFrom] = useState(''); +const [to, setTo] = useState(''); +// ... +
+
+ + + + + + + + + + + ... +``` + +نوع ردیف (`useMyPayments.ts`) — دقت کن `status` موجود است ولی رندر نمی‌شود: + +```ts +export type PaymentRowStatus = 'paid' | 'unsettled'; + +export interface PaymentRow { + invoice_uuid: string; + patient_uuid: string; + patient_name: string | null; + national_code: string | null; + issued_at: number; + amount_rials: number; + status: PaymentRowStatus; +} +``` + +--- + +## وظایف + +### ۱. اندپوینت خلاصه‌ی پرداخت‌ها (بک‌اند) + +طبق قاعده‌ی «اول بگرد، بعد توسعه بده، در آخر بساز»: هیچ اندپوینتی خلاصه‌ی مالی tenant را برنمی‌گرداند (`/api/v1/billing/reports/insurance-debt` فقط بدهی بیمه است، نه پرداخت‌های بیمار). پس یک اندپوینت جدید لازم است — اما **همان فیلترهای `listPayments` را می‌پذیرد** تا کارت‌ها با جدول هم‌خوان بمانند. + +در `InvoiceService`: + +```php +/** + * خلاصه‌ی مالی صورتحساب‌های tenant با همان فیلترهای tenantInvoiceList. + * @return array{total_rials:int, paid_rials:int, unsettled_rials:int, invoices_count:int} + */ +public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array +``` + +پیاده‌سازی با یک DQL aggregate (`SUM`/`COUNT` + `CASE WHEN status = 'paid'`), **نه** با بارگذاری همه‌ی ردیف‌ها در PHP. شرط‌های فیلتر (`national_code`, `status`, `from`, `to`) را دقیقاً از `tenantInvoiceList` بازاستفاده کن — منطق `where` را در یک متد private مشترک بگذار تا دو نسخه از هم واگرا نشوند (SOLID/DRY). + +در `BillingController` کنار `listPayments`: + +```php +#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])] +public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse +{ + [$entityType, $entityId] = $this->resolveEntity($user); + if ($entityId === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + // همان استخراج $filters که در listPayments هست + return $this->success($this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $filters)); +} +``` + +**دقت:** payload را مستقیم پاس بده (`$this->success($summary)`) نه `['data' => $summary]` — در غیر این‌صورت فرانت باید `data?.data?.data` بخواند (pitfall نامبرده در CLAUDE.md). + +**نکته‌ی مسیریابی:** روت `/payments/summary` نباید با روت‌های پارامتری موجود تداخل کند؛ بعد از افزودن، با `ddev exec php bin/console debug:router | grep billing` تأیید کن. + +### ۲. hook خلاصه در فرانت + +در `assets/admin/hooks/useMyPayments.ts`: + +```ts +export interface PaymentsSummary { + total_rials: number; + paid_rials: number; + unsettled_rials: number; + invoices_count: number; +} + +/** خلاصه‌ی مالی با همان فیلترهای لیست — کارت‌های آمار همیشه با جدول هم‌خوان می‌مانند. */ +export function usePaymentsSummary(filters: Omit) { + const qs = new URLSearchParams(); + if (filters.national_code) qs.set('national_code', filters.national_code); + if (filters.status) qs.set('status', filters.status); + if (filters.from) qs.set('from', String(filters.from)); + if (filters.to) qs.set('to', String(filters.to)); + + return useQuery>({ + queryKey: ['payments-summary', filters], + queryFn: () => api.get(`/api/v1/my/billing/payments/summary?${qs.toString()}`), + }); +} +``` + +خواندن در صفحه: `summaryQuery.data?.data`. + +### ۳. بج وضعیت صورتحساب + +`StatusBadge` هیچ mapی برای `paid | unsettled` ندارد (`paymentMap` مربوط به درگاه است: `pending/success/failed/...`). یک type جدید اضافه کن — map موجود را دستکاری نکن: + +در `types/index.ts`: + +```ts +export type InvoiceListStatus = 'paid' | 'unsettled'; +``` + +در `StatusBadge.tsx`: + +```ts +const invoiceMap: Record = { + paid: { color: 'green', label: 'پرداخت شده' }, + unsettled: { color: 'amber', label: 'تسویه نشده' }, +}; +``` + +و `'invoice'` را به union پراپ `type` اضافه کن و در بدنه هندل کن. + +### ۴. بازنویسی `MyPaymentsPage.tsx` بر اساس الگوی `ClaimsPage` + +ساختار نهایی دقیقاً به این ترتیب: + +```tsx +<> + + + {/* ۴ کارت آمار */} +
+ + + + +
+ +
+ {/* ردیف فیلترها: وضعیت + از تاریخ + تا تاریخ + میان‌برها + پاک‌کردن */} + {/* DataTable */} + {/* Pagination — فقط وقتی total > MY_PAYMENTS_LIMIT */} +
+ +``` + +**۴-۱ — انتقال state به query string.** `useState`های `page/nationalCode/status/from/to` را با `useSearchParams` جایگزین کن، دقیقاً با همان `setParam` صفحه‌ی claims (که با هر تغییر فیلتر، `page` را حذف می‌کند): + +```tsx +const [params, setParams] = useSearchParams(); +const search = params.get('search') ?? ''; // کد ملی / نام +const status = params.get('status') ?? ''; +const from = params.get('from') ?? ''; +const to = params.get('to') ?? ''; +const page = Math.max(1, Number(params.get('page') ?? 1)); + +const setParam = (patch: Record) => { + const next = new URLSearchParams(params); + Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k))); + if (!('page' in patch)) next.delete('page'); + setParams(next, { replace: true }); +}; +``` + +**۴-۲ — جستجو داخل `DataTable`.** `input` دست‌ساز و آیکون ذره‌بین را حذف کن؛ به‌جایش: + +```tsx +searchValue={search} +onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })} +searchPlaceholder="کد ملی بیمار" +``` + +مقدار به‌عنوان `national_code` به `usePayments` می‌رود (API فقط `national_code` را می‌شناسد؛ جستجوی نام سمت سرور وجود ندارد — placeholder را همین‌طور صادقانه بگذار و ادعای جستجوی نام نکن). + +**۴-۳ — فیلترها با label، مثل claims.** هر کنترل داخل یک `
` با `
- -
- { setStatus(v ? String(v) : ''); reset(); }} - placeholder="همه وضعیت‌ها" - isClearable - height={38} - /> -
- -
- { setFrom(v); reset(); }} placeholder="از تاریخ" /> -
-
- { setTo(v); reset(); }} placeholder="تا تاریخ" /> -
+
+ + + +
- {isLoading ? ( -
در حال بارگذاری…
- ) : rows.length === 0 ? ( -
- -
پرداختی یافت نشد
-
با ثبت صورتحساب برای بیماران، این فهرست پر می‌شود.
+
+
+
+ + setParam({ status: v ? String(v) : '' })} + placeholder="همه وضعیت‌ها" + height={38} + /> +
+
+ + setParam({ from: v })} height={38} /> +
+
+ + setParam({ to: v })} height={38} /> +
+
+ + + {hasFilters && ( + + )} +
- ) : ( - <> -
-
ردیفنام بیمارکد ملیتاریخمبلغ پرداخت‌شدهعملیات
- - - - - - - - - - - - {rows.map((row, i) => ( - - - - - - - - - ))} - -
ردیفنام بیمارکد ملیتاریخمبلغ پرداخت‌شدهعملیات
{formatNumber((page - 1) * MY_PAYMENTS_LIMIT + i + 1)} -
- - {row.patient_name ?? '—'} -
-
{row.national_code ?? '—'}{formatDate(row.issued_at)} - {formatTime(row.issued_at)}{formatRial(row.amount_rials)} - -
- -
- -
- - )} + + setParam({ search: v.replace(/\D/g, '') })} + searchPlaceholder="کد ملی بیمار" + emptyMessage="پرداختی ثبت نشده است." + actions={(row) => ( + + )} + /> + + {total > MY_PAYMENTS_LIMIT && ( + setParam({ page: String(p) })} /> + )} + ); } diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 9bfc7d57..c1a396e6 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -204,6 +204,9 @@ export type SettlementStatus = "pending" | "approved" | "rejected"; /** `mixed` فقط در نمای تجمیعی بیمار معنا دارد: مطالبات آن بیمار وضعیت یکسان ندارند. */ export type ClaimStatus = "pending" | "submitted" | "approved" | "rejected" | "paid" | "mixed"; +/** وضعیت دوحالته‌ی صورتحساب در فهرست پرداخت‌ها — با `PaymentStatus` درگاه فرق دارد. */ +export type InvoiceListStatus = "paid" | "unsettled"; + export interface ClaimPatientRow { patient_uuid: string; record_uuid: string; diff --git a/docs/api/billing.md b/docs/api/billing.md index 5e171250..0076838b 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -345,6 +345,27 @@ **Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد. +## GET /api/v1/my/billing/payments/summary +خلاصه‌ی مالی **همان مجموعه‌ی فیلترشده‌ی** `GET /api/v1/my/billing/payments` — برای کارت‌های آمار بالای صفحه‌ی «لیست پرداخت‌ها». همان دامنه‌ی رکوردها (فقط `finalized`/`paid` همان tenant). + +**Query params:** دقیقاً `national_code`، `status`، `from`، `to` مثل اندپوینت لیست (بدون `page`/`limit`). + +**Response 200:** +```json +{ + "success": true, + "data": { + "total_rials": 8350000, + "paid_rials": 2350000, + "unsettled_rials": 6000000, + "invoices_count": 2 + } +} +``` +> مبالغ جمع `patient_rials` هستند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر می‌شود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همه‌ی مقادیر `0` برمی‌گردند. + +**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد. + ## GET /api/v1/my/billing/patients/{patientUuid}/invoices «پرداخت‌های ثبت‌شده» — سربرگ بیمار + فهرست صفحه‌بندی‌شده‌ی صورتحساب‌های `finalized`/`paid` او (جدیدترین اول). فقط مالک رکورد (همان tenant) دسترسی دارد. diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php index 174ade6f..166e2085 100644 --- a/src/Billing/Controller/BillingController.php +++ b/src/Billing/Controller/BillingController.php @@ -168,18 +168,50 @@ class BillingController extends BaseController return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } - $filters = [ + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 20))); + + $result = $this->invoiceService->tenantInvoiceList( + $entityType, + $entityId, + $this->paymentFilters($request), + $page, + $limit, + ); + + return $this->paginated($result['items'], $result['total'], $page, $limit); + } + + /** + * خلاصه‌ی مالی همان مجموعه‌ی فیلترشده‌ی listPayments — برای کارت‌های آمار. + * پاسخ: { total_rials, paid_rials, unsettled_rials, invoices_count }. + */ + #[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])] + public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + if ($entityId === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + return $this->success( + $this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $this->paymentFilters($request)), + ); + } + + /** + * فیلترهای مشترک لیست پرداخت‌ها و خلاصه‌ی آن؛ یک منبع تا دو نما واگرا نشوند. + * + * @return array{national_code:?string,status:?string,from:?string,to:?string} + */ + private function paymentFilters(Request $request): array + { + return [ 'national_code' => $request->query->get('national_code') ?: null, 'status' => $request->query->get('status') ?: null, 'from' => $request->query->get('from') ?: null, 'to' => $request->query->get('to') ?: null, ]; - $page = max(1, (int) $request->query->get('page', 1)); - $limit = min(100, max(1, (int) $request->query->get('limit', 20))); - - $result = $this->invoiceService->tenantInvoiceList($entityType, $entityId, $filters, $page, $limit); - - return $this->paginated($result['items'], $result['total'], $page, $limit); } /** diff --git a/src/Billing/Repository/InvoiceRepository.php b/src/Billing/Repository/InvoiceRepository.php index 9c7f4aeb..c8f90b44 100644 --- a/src/Billing/Repository/InvoiceRepository.php +++ b/src/Billing/Repository/InvoiceRepository.php @@ -67,6 +67,36 @@ class InvoiceRepository extends ServiceEntityRepository ->getSingleScalarResult(); } + /** + * Aggregate totals over the same filtered set as {@see tenantInvoices}, so the + * summary cards always agree with the table below them. + * + * @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters + * @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int} + */ + public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array + { + $row = $this->tenantInvoicesQuery($entityType, $entityId, $filters) + ->select( + 'COALESCE(SUM(i.patientRials), 0) AS total_rials', + 'COALESCE(SUM(CASE WHEN i.status = :paidStatus THEN i.patientRials ELSE 0 END), 0) AS paid_rials', + 'COUNT(i.id) AS invoices_count', + ) + ->setParameter('paidStatus', Invoice::STATUS_PAID) + ->getQuery() + ->getSingleResult(); + + $total = (int) $row['total_rials']; + $paid = (int) $row['paid_rials']; + + return [ + 'total_rials' => $total, + 'paid_rials' => $paid, + 'unsettled_rials' => $total - $paid, + 'invoices_count' => (int) $row['invoices_count'], + ]; + } + private function tenantInvoicesQuery(string $entityType, int $entityId, array $filters): QueryBuilder { $qb = $this->createQueryBuilder('i') diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index 45465aeb..ec6ecd8c 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -109,6 +109,18 @@ class InvoiceService ]; } + /** + * Financial summary of the same filtered invoice set as {@see tenantInvoiceList}, + * used by the payments page stat cards. + * + * @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters + * @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int} + */ + public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array + { + return $this->invoiceRepo->tenantInvoiceSummary($entityType, $entityId, $filters); + } + /** * A patient's recorded invoices, shaped for the detail table: number, issue * time, a single service title (first item, "+ more" when several), total, diff --git a/tests/Billing/PaymentsSummaryTest.php b/tests/Billing/PaymentsSummaryTest.php new file mode 100644 index 00000000..a951984d --- /dev/null +++ b/tests/Billing/PaymentsSummaryTest.php @@ -0,0 +1,140 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + return [$owner, $doctor]; + } + + private function patientRecord(Doctor $doctor, ?string $nationalCode = null): PatientRecord + { + $patient = $this->createUser(['ROLE_USER']); + if ($nationalCode !== null) { + $patient->setNationalCode($nationalCode); + } + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return $record; + } + + private function setField(object $obj, string $prop, mixed $value): void + { + $ref = new \ReflectionProperty($obj, $prop); + $ref->setAccessible(true); + $ref->setValue($obj, $value); + } + + private function invoice(Doctor $doctor, PatientRecord $record, int $patientRials, string $status, int $issuedAt): Invoice + { + $invoice = new Invoice('doctor', $doctor->getId()); + $invoice->setPatientRecordId($record->getId()); + $this->setField($invoice, 'patientRials', $patientRials); + $this->setField($invoice, 'status', $status); + $this->setField($invoice, 'issuedAt', $issuedAt); + $this->em->persist($invoice); + $this->em->flush(); + + return $invoice; + } + + public function testSummarySplitsPaidAndUnsettled(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_100); + // draft invoices are not part of the payments list, so they must not count + $this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200); + + $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner); + self::assertSame(200, $this->responseCode()); + + $data = $res['data']; + self::assertSame(1_400_000, $data['total_rials']); + self::assertSame(1_000_000, $data['paid_rials']); + self::assertSame(400_000, $data['unsettled_rials']); + self::assertSame(2, $data['invoices_count']); + } + + public function testSummaryHonoursStatusAndDateFilters(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_000); + $this->invoice($doctor, $record, 700_000, Invoice::STATUS_PAID, 1_800_000_000); + + $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?status=paid', $owner); + self::assertSame(1_700_000, $res['data']['total_rials']); + self::assertSame(1_700_000, $res['data']['paid_rials']); + self::assertSame(0, $res['data']['unsettled_rials']); + self::assertSame(2, $res['data']['invoices_count']); + + $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?from=1700000000&to=1700000001', $owner); + self::assertSame(1_400_000, $res['data']['total_rials']); + self::assertSame(2, $res['data']['invoices_count']); + } + + public function testSummaryFiltersByNationalCode(): void + { + [$owner, $doctor] = $this->doctor(); + // db_test is never reset — a fixed code would eventually collide on the unique column + $code = (string) random_int(1_000_000_000, 9_999_999_999); + $mine = $this->patientRecord($doctor, $code); + $other = $this->patientRecord($doctor, (string) random_int(1_000_000_000, 9_999_999_999)); + + $this->invoice($doctor, $mine, 500_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $other, 800_000, Invoice::STATUS_PAID, 1_700_000_000); + + $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?national_code=' . $code, $owner); + self::assertSame(500_000, $res['data']['total_rials']); + self::assertSame(1, $res['data']['invoices_count']); + } + + public function testSummaryIsZeroWhenNothingMatches(): void + { + [$owner] = $this->doctor(); + + $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner); + self::assertSame(200, $this->responseCode()); + self::assertSame( + ['total_rials' => 0, 'paid_rials' => 0, 'unsettled_rials' => 0, 'invoices_count' => 0], + $res['data'], + ); + } + + public function testSummaryExcludesOtherTenants(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + $this->invoice($doctor, $record, 300_000, Invoice::STATUS_PAID, 1_700_000_000); + + [$otherOwner] = $this->doctor(); + $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $otherOwner); + self::assertSame(0, $res['data']['total_rials']); + self::assertSame(0, $res['data']['invoices_count']); + } +}