226 lines
9.6 KiB
PHP
226 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Billing;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Billing\Entity\Invoice;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Patient\Entity\PatientRecord;
|
|
use App\Patient\Entity\PatientSession;
|
|
use App\Patient\Entity\SessionPayment;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* GET /api/v1/my/billing/payments/summary — aggregate totals over the same
|
|
* filtered set as the payments list, feeding the admin page's stat cards.
|
|
*/
|
|
class PaymentsSummaryTest 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];
|
|
}
|
|
|
|
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']);
|
|
}
|
|
}
|