Port two nobat724 Figma screens into the admin SPA for the doctor/clinic
tenant panel:
- node 1 — لیست پرداختها (/admin/my-payments): per-patient payment summary
(invoice count, paid, remaining, derived status paid/unsettled/unpaid),
filters by national code / status / Jalali date range, pagination.
- node 2 — پرداختهای ثبتشده (/admin/my-payments/:patientUuid): a patient's
recorded invoices with patient header, service title, total, status badge,
and an expandable per-invoice item breakdown.
Backend (App\Billing):
- InvoiceRepository::patientPaymentSummary/countPatientPaymentSummary — DQL
aggregation grouped by patient record (arbitrary join Invoice→PatientRecord
→User), draft/void excluded, derived-status HAVING filters.
- InvoiceRepository::invoicesForPatient/count + InvoiceService methods that
shape rows and derive status.
- BillingController: GET /api/v1/my/billing/patient-payments and
GET /api/v1/my/billing/patients/{patientUuid}/invoices (thin, resolveEntity,
tenant-scoped, 403/404). Invoice::getIssuedAt / InvoiceItem::getTitle added.
- docs/api/billing.md documents both endpoints.
Frontend: useMyPayments hooks, MyPaymentsPage, MyPaymentDetailPage, routes in
App.tsx (doctor/secretary/clinic, blockClinicScope) and a sidebar entry.
Persian strings hardcoded per existing admin convention (no i18n infra).
Tests: tests/Billing/PatientPaymentsTest.php (8), useMyPayments + both page
tests (11). Note: pre-existing LoginPage.test failures are unrelated (proven
by stashing this change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
222 lines
9.2 KiB
PHP
222 lines
9.2 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/patient-payments — per-patient payment summary for the
|
|
* caller's tenant, with paid/remaining aggregates and a derived row status.
|
|
*/
|
|
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 testAggregatesPerPatientWithDerivedStatus(): 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
|
|
|
|
$b = $this->patientRecord($doctor, 'علی بدیعی');
|
|
$this->invoice($doctor, $b, Invoice::STATUS_PAID, 200000);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/my/billing/patient-payments', $owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(2, $res['meta']['totalRecords']);
|
|
|
|
$byUuid = [];
|
|
foreach ($res['data'] as $row) {
|
|
$byUuid[$row['patient_uuid']] = $row;
|
|
}
|
|
|
|
$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']);
|
|
}
|
|
|
|
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);
|
|
|
|
// national_code (partial match)
|
|
$byCode = $this->authJson('GET', '/api/v1/my/billing/patient-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);
|
|
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']);
|
|
}
|
|
|
|
public function testEmptyWhenNoInvoices(): void
|
|
{
|
|
[$owner] = $this->doctor();
|
|
$res = $this->authJson('GET', '/api/v1/my/billing/patient-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/patient-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']);
|
|
}
|
|
}
|