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 paidSession(PatientRecord $record, int $paidRials): PatientSession { $session = new PatientSession($record); $this->em->persist($session); $this->em->flush(); $payment = new SessionPayment($session, 'cash', $paidRials, 1_700_000_000); $this->em->persist($payment); $this->em->flush(); return $session; } /** * `totalRials` دو برابر سهم بیمار است تا دو خلاصه از هم قابل‌تفکیک بمانند. * `$paidRials` پرداخت واقعی روی مراجعه است؛ وضعیت پرداخت از همین مشتق می‌شود. */ private function invoice( Doctor $doctor, PatientRecord $record, int $patientRials, string $status, int $issuedAt, int $paidRials = 0, ): Invoice { $invoice = new Invoice('doctor', $doctor->getId()); $invoice->setPatientRecordId($record->getId()); if ($paidRials > 0) { $invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId()); } $this->setField($invoice, 'totalRials', $patientRials * 2); $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_FINALIZED, 1_700_000_000, 1_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, 999_000); $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 testPartiallyPaidInvoiceCountsItsCollectedAmount(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); // سهم بیمار ۱٬۰۰۰٬۰۰۰ و فقط ۴۰۰٬۰۰۰ وصول شده. $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 400_000); $data = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner)['data']; self::assertSame(1_000_000, $data['total_rials']); self::assertSame(400_000, $data['paid_rials']); self::assertSame(600_000, $data['unsettled_rials']); } /** ⚠️ مرزی: اضافه‌پرداخت نباید «تسویه‌نشده» را منفی کند. */ public function testOverpaymentIsCappedAtTheInvoiceShare(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); $this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 900_000); $data = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner)['data']; self::assertSame(500_000, $data['paid_rials']); self::assertSame(0, $data['unsettled_rials']); } public function testSummaryHonoursStatusAndDateFilters(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000); $this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_000); $this->invoice($doctor, $record, 700_000, Invoice::STATUS_FINALIZED, 1_800_000_000, 700_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_FINALIZED, 1_700_000_000, 500_000); $this->invoice($doctor, $other, 800_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 800_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 testPatientInvoicesCarryTheirOwnSummary(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000); $this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_100); $this->invoice($doctor, $record, 900_000, Invoice::STATUS_DRAFT, 1_700_000_200, 900_000); $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); self::assertSame(200, $this->responseCode()); // ستون «مجموع» جمعِ کلِ صورتحساب است، ولی وصولی و بدهی روی سهم بیمار سنجیده // می‌شوند: فاکتور اول (سهم ۱٬۰۰۰٬۰۰۰) تسویه شده و فاکتور دوم (۵۰۰٬۰۰۰) نه. $summary = $res['data']['summary']; self::assertSame(3_000_000, $summary['total_rials']); self::assertSame(1_000_000, $summary['paid_rials']); self::assertSame(500_000, $summary['unsettled_rials']); self::assertSame(2, $summary['invoices_count']); self::assertSame(2, $res['data']['meta']['totalRecords']); } public function testSummaryExcludesOtherTenants(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); $this->invoice($doctor, $record, 300_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 300_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']); } }