refactor(billing): rebuild payments list as flat invoice list (tauri parity)
Align /admin/my-payments with the tauri /payments source (per user): the list
is now a flat, newest-first list of the tenant's recorded invoices — one row
per invoice — instead of the per-patient aggregation built from Figma.
Backend:
- replace InvoiceRepository::patientPaymentSummary aggregation with
tenantInvoices/countTenantInvoices (flat, joins patient name/national code).
- InvoiceService::patientPaymentList → tenantInvoiceList.
- BillingController: GET /api/v1/my/billing/patient-payments →
GET /api/v1/my/billing/payments returning
{ invoice_uuid, patient_uuid, patient_name, national_code, issued_at,
amount_rials, status } rows.
- node-2 patient invoices endpoint unchanged.
Frontend:
- useMyPayments: usePatientPayments → usePayments (flat PaymentRow).
- MyPaymentsPage columns match tauri DetailT: row #, avatar+name, national
code, date-time, amount paid, مشاهده (no status column); 'اضافه کردن بیمار'
links to /admin/patients/new. Filters (national code / status / Jalali date
range) kept.
Tests + docs/api/billing.md updated. Intentionally omitted tauri extras:
mobile Cards view and the advanced ModalFilter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,8 @@ use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/my/billing/patient-payments — per-patient payment summary for the
|
||||
* caller's tenant, with paid/remaining aggregates and a derived row status.
|
||||
* GET /api/v1/my/billing/payments — flat, tenant-scoped list of recorded
|
||||
* invoices, and GET .../patients/{uuid}/invoices — one patient's invoices.
|
||||
*/
|
||||
class PatientPaymentsTest extends ApiTestCase
|
||||
{
|
||||
@@ -82,51 +82,30 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
$ref->setValue($obj, $value);
|
||||
}
|
||||
|
||||
public function testAggregatesPerPatientWithDerivedStatus(): void
|
||||
public function testListsFlatInvoicesNewestFirst(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$a = $this->patientRecord($doctor, 'دنیا خلیلی');
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_PAID, 100000);
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000);
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999); // ignored
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999); // ignored
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_PAID, 100000, 2000);
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000);
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999, 3000); // excluded
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999, 3000); // excluded
|
||||
|
||||
$b = $this->patientRecord($doctor, 'علی بدیعی');
|
||||
$this->invoice($doctor, $b, Invoice::STATUS_PAID, 200000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patient-payments', $owner);
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(2, $res['meta']['totalRecords']);
|
||||
self::assertSame(2, $res['meta']['totalRecords']); // draft/void excluded
|
||||
|
||||
$byUuid = [];
|
||||
foreach ($res['data'] as $row) {
|
||||
$byUuid[$row['patient_uuid']] = $row;
|
||||
}
|
||||
// newest (issued_at DESC) first
|
||||
self::assertSame('دنیا خلیلی', $res['data'][0]['patient_name']);
|
||||
self::assertSame($this->nationalCodeOf($a), $res['data'][0]['national_code']);
|
||||
self::assertSame(100000, $res['data'][0]['amount_rials']);
|
||||
self::assertSame('paid', $res['data'][0]['status']);
|
||||
self::assertArrayHasKey('invoice_uuid', $res['data'][0]);
|
||||
self::assertSame($a->getUuid(), $res['data'][0]['patient_uuid']);
|
||||
|
||||
$rowA = $byUuid[$a->getUuid()];
|
||||
self::assertSame('دنیا خلیلی', $rowA['patient_name']);
|
||||
self::assertSame(2, $rowA['invoice_count']); // draft/void excluded
|
||||
self::assertSame(100000, $rowA['paid_rials']);
|
||||
self::assertSame(50000, $rowA['remaining_rials']);
|
||||
self::assertSame('unsettled', $rowA['status']);
|
||||
|
||||
$rowB = $byUuid[$b->getUuid()];
|
||||
self::assertSame(200000, $rowB['paid_rials']);
|
||||
self::assertSame(0, $rowB['remaining_rials']);
|
||||
self::assertSame('paid', $rowB['status']);
|
||||
}
|
||||
|
||||
public function testUnpaidStatusWhenNothingPaid(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$c = $this->patientRecord($doctor, 'مازیار عزیزی');
|
||||
$this->invoice($doctor, $c, Invoice::STATUS_FINALIZED, 300000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patient-payments', $owner);
|
||||
self::assertSame('unpaid', $res['data'][0]['status']);
|
||||
self::assertSame(0, $res['data'][0]['paid_rials']);
|
||||
self::assertSame(300000, $res['data'][0]['remaining_rials']);
|
||||
self::assertSame(50000, $res['data'][1]['amount_rials']);
|
||||
self::assertSame('unsettled', $res['data'][1]['status']);
|
||||
}
|
||||
|
||||
public function testFiltersByNationalCodeAndStatus(): void
|
||||
@@ -137,25 +116,23 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
$this->invoice($doctor, $paid, Invoice::STATUS_PAID, 100000);
|
||||
$this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000);
|
||||
|
||||
// national_code (partial match)
|
||||
$byCode = $this->authJson('GET', '/api/v1/my/billing/patient-payments?national_code=' . $this->nationalCodeOf($paid), $owner);
|
||||
$byCode = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $this->nationalCodeOf($paid), $owner);
|
||||
self::assertSame(1, $byCode['meta']['totalRecords']);
|
||||
self::assertSame($paid->getUuid(), $byCode['data'][0]['patient_uuid']);
|
||||
|
||||
// derived status
|
||||
$onlyPaid = $this->authJson('GET', '/api/v1/my/billing/patient-payments?status=paid', $owner);
|
||||
$onlyPaid = $this->authJson('GET', '/api/v1/my/billing/payments?status=paid', $owner);
|
||||
self::assertSame(1, $onlyPaid['meta']['totalRecords']);
|
||||
self::assertSame('paid', $onlyPaid['data'][0]['status']);
|
||||
|
||||
$onlyUnpaid = $this->authJson('GET', '/api/v1/my/billing/patient-payments?status=unpaid', $owner);
|
||||
self::assertSame(1, $onlyUnpaid['meta']['totalRecords']);
|
||||
self::assertSame($unpaid->getUuid(), $onlyUnpaid['data'][0]['patient_uuid']);
|
||||
$onlyUnsettled = $this->authJson('GET', '/api/v1/my/billing/payments?status=unsettled', $owner);
|
||||
self::assertSame(1, $onlyUnsettled['meta']['totalRecords']);
|
||||
self::assertSame($unpaid->getUuid(), $onlyUnsettled['data'][0]['patient_uuid']);
|
||||
}
|
||||
|
||||
public function testEmptyWhenNoInvoices(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patient-payments', $owner);
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(0, $res['meta']['totalRecords']);
|
||||
self::assertCount(0, $res['data']);
|
||||
@@ -164,7 +141,7 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
public function testForbiddenWithoutProfile(): void
|
||||
{
|
||||
$orphan = $this->createUser(['ROLE_DOCTOR']); // ROLE_DOCTOR but no Doctor row
|
||||
$this->authJson('GET', '/api/v1/my/billing/patient-payments', $orphan);
|
||||
$this->authJson('GET', '/api/v1/my/billing/payments', $orphan);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user