Files
clinicpro/tests/Billing/PatientPaymentsTest.php
T
hamedandClaude Opus 4.8 818506bf36 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>
2026-07-14 19:00:17 +03:30

199 lines
8.4 KiB
PHP

<?php
namespace App\Tests\Billing;
use App\Auth\Entity\User;
use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\ValueObject\ShareBreakdown;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* 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
{
/** @return array{0: User, 1: Doctor} owner user + their doctor profile */
private function doctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
// national_code is UNIQUE and db_test is never reset, so randomise it per
// patient (like mobile) to avoid cross-run collisions; read it back from the
// record's user when a test needs to filter by it.
private function patientRecord(Doctor $doctor, string $realName): PatientRecord
{
$patient = $this->createUser(['ROLE_USER']);
$this->setField($patient, 'realName', $realName);
$this->setField($patient, 'nationalCode', str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT));
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return $record;
}
private function nationalCodeOf(PatientRecord $record): string
{
return $record->getUser()->getNationalCode();
}
private function invoice(
Doctor $doctor,
PatientRecord $record,
string $status,
int $patientRials,
?int $issuedAt = null,
?string $itemTitle = null,
int $totalRials = 0,
): Invoice {
$invoice = new Invoice('doctor', $doctor->getId());
$invoice->setPatientRecordId($record->getId());
if ($itemTitle !== null) {
// Item added only for its title (service_title); totals set below by hand.
$invoice->addItem(new InvoiceItem($invoice, $itemTitle, $totalRials, 1, new ShareBreakdown($totalRials, 0, 0, $patientRials), null));
}
$this->setField($invoice, 'status', $status);
$this->setField($invoice, 'patientRials', $patientRials);
$this->setField($invoice, 'totalRials', $totalRials);
if ($issuedAt !== null) {
$this->setField($invoice, 'issuedAt', $issuedAt);
}
$this->em->persist($invoice);
$this->em->flush();
return $invoice;
}
private function setField(object $obj, string $prop, mixed $value): void
{
$ref = new \ReflectionProperty($obj, $prop);
$ref->setAccessible(true);
$ref->setValue($obj, $value);
}
public function testListsFlatInvoicesNewestFirst(): void
{
[$owner, $doctor] = $this->doctor();
$a = $this->patientRecord($doctor, 'دنیا خلیلی');
$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
$res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(2, $res['meta']['totalRecords']); // draft/void excluded
// 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']);
self::assertSame(50000, $res['data'][1]['amount_rials']);
self::assertSame('unsettled', $res['data'][1]['status']);
}
public function testFiltersByNationalCodeAndStatus(): void
{
[$owner, $doctor] = $this->doctor();
$paid = $this->patientRecord($doctor, 'بیمار پرداخت');
$unpaid = $this->patientRecord($doctor, 'بیمار بدهکار');
$this->invoice($doctor, $paid, Invoice::STATUS_PAID, 100000);
$this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000);
$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']);
$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']);
$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/payments', $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(0, $res['meta']['totalRecords']);
self::assertCount(0, $res['data']);
}
public function testForbiddenWithoutProfile(): void
{
$orphan = $this->createUser(['ROLE_DOCTOR']); // ROLE_DOCTOR but no Doctor row
$this->authJson('GET', '/api/v1/my/billing/payments', $orphan);
self::assertSame(403, $this->responseCode());
}
// ── Node 2: a patient's recorded invoices ────────────────────────────────
public function testListsPatientInvoicesWithHeaderAndDerivedStatus(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor, 'دنیا خلیلی');
$this->invoice($doctor, $record, Invoice::STATUS_PAID, 235000, 1000, 'روکش دندان', 235000);
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 600000, 2000, 'طرح لبخند', 600000);
$this->invoice($doctor, $record, Invoice::STATUS_DRAFT, 111, 3000, 'پیش‌نویس', 111); // excluded
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
self::assertSame(200, $this->responseCode());
// header
self::assertSame('دنیا خلیلی', $res['data']['patient']['name']);
self::assertSame($this->nationalCodeOf($record), $res['data']['patient']['national_code']);
// invoices — draft excluded, newest (issued_at DESC) first
self::assertSame(2, $res['data']['meta']['totalRecords']);
$rows = $res['data']['data'];
self::assertCount(2, $rows);
self::assertSame('طرح لبخند', $rows[0]['service_title']);
self::assertSame('unsettled', $rows[0]['status']);
self::assertSame(600000, $rows[0]['total_rials']);
self::assertSame('روکش دندان', $rows[1]['service_title']);
self::assertSame('paid', $rows[1]['status']);
}
public function testPatientInvoicesNotFoundForOtherTenant(): void
{
[, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor, 'بیمار');
[$other] = $this->doctor();
$this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $other);
self::assertSame(404, $this->responseCode());
}
public function testPatientInvoicesEmpty(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor, 'بدون فاکتور');
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(0, $res['data']['meta']['totalRecords']);
self::assertCount(0, $res['data']['data']);
self::assertSame('بدون فاکتور', $res['data']['patient']['name']);
}
}