feat: enhance payment status handling and summary in MyPaymentsPage
- Added support for 'partial' payment status in MyPaymentsPage and related components. - Updated API responses to include 'paid_rials' and 'summary' for invoices. - Introduced InvoicePaymentStatus service to derive payment status based on actual payments. - Enhanced tests to cover new payment scenarios including partial payments and payment methods. - Updated documentation to reflect changes in payment status and API responses.
This commit is contained in:
@@ -8,6 +8,8 @@ use App\Billing\Entity\InvoiceItem;
|
||||
use App\Billing\ValueObject\ShareBreakdown;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Entity\SessionPayment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
@@ -48,6 +50,10 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
return $record->getUser()->getNationalCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* `$status` چرخهی حیات صورتحساب است (draft/finalized/void). وضعیت پرداختِ
|
||||
* نمایشدادهشده از `$paidRials` مشتق میشود — پرداخت واقعی روی مراجعه.
|
||||
*/
|
||||
private function invoice(
|
||||
Doctor $doctor,
|
||||
PatientRecord $record,
|
||||
@@ -56,9 +62,13 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
?int $issuedAt = null,
|
||||
?string $itemTitle = null,
|
||||
int $totalRials = 0,
|
||||
int $paidRials = 0,
|
||||
): Invoice {
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientRecordId($record->getId());
|
||||
if ($paidRials > 0) {
|
||||
$invoice->setPatientSessionId($this->paidSession($record, $paidRials)->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));
|
||||
@@ -75,6 +85,20 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
/** مراجعهای با یک پرداخت نقدی ثبتشده — منبع واقعیِ «چقدر وصول شده». */
|
||||
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, time());
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private function setField(object $obj, string $prop, mixed $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty($obj, $prop);
|
||||
@@ -87,8 +111,8 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
[$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_FINALIZED, 100000, 2000, null, 0, 100000); // fully paid
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000); // nothing paid
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999, 3000); // excluded
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999, 3000); // excluded
|
||||
|
||||
@@ -111,10 +135,12 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
public function testFiltersByNationalCodeAndStatus(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$paid = $this->patientRecord($doctor, 'بیمار پرداخت');
|
||||
$unpaid = $this->patientRecord($doctor, 'بیمار بدهکار');
|
||||
$this->invoice($doctor, $paid, Invoice::STATUS_PAID, 100000);
|
||||
$paid = $this->patientRecord($doctor, 'بیمار پرداخت');
|
||||
$unpaid = $this->patientRecord($doctor, 'بیمار بدهکار');
|
||||
$partial = $this->patientRecord($doctor, 'بیمار نیمهپرداخت');
|
||||
$this->invoice($doctor, $paid, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 100000);
|
||||
$this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000);
|
||||
$this->invoice($doctor, $partial, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 40000);
|
||||
|
||||
$byCode = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $this->nationalCodeOf($paid), $owner);
|
||||
self::assertSame(1, $byCode['meta']['totalRecords']);
|
||||
@@ -127,6 +153,12 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
$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']);
|
||||
|
||||
$onlyPartial = $this->authJson('GET', '/api/v1/my/billing/payments?status=partial', $owner);
|
||||
self::assertSame(1, $onlyPartial['meta']['totalRecords']);
|
||||
self::assertSame($partial->getUuid(), $onlyPartial['data'][0]['patient_uuid']);
|
||||
self::assertSame('partial', $onlyPartial['data'][0]['status']);
|
||||
self::assertSame(40000, $onlyPartial['data'][0]['paid_rials']);
|
||||
}
|
||||
|
||||
public function testEmptyWhenNoInvoices(): void
|
||||
@@ -152,7 +184,7 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
[$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, 235000, 1000, 'روکش دندان', 235000, 235000);
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 600000, 2000, 'طرح لبخند', 600000);
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_DRAFT, 111, 3000, 'پیشنویس', 111); // excluded
|
||||
|
||||
@@ -174,6 +206,47 @@ class PatientPaymentsTest extends ApiTestCase
|
||||
self::assertSame('paid', $rows[1]['status']);
|
||||
}
|
||||
|
||||
public function testPatientInvoiceRowsCarryTheirPaymentMethods(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'دنیا خلیلی');
|
||||
$session = $this->paidSession($record, 60000);
|
||||
|
||||
$extra = new SessionPayment($session, 'pos', 40000, time() + 60);
|
||||
$this->em->persist($extra);
|
||||
$this->em->flush();
|
||||
|
||||
$invoice = $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'روکش دندان', 100000);
|
||||
$invoice->setPatientSessionId($session->getId());
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
$row = $res['data']['data'][0];
|
||||
|
||||
self::assertSame('paid', $row['status']);
|
||||
self::assertSame(100000, $row['paid_rials']);
|
||||
self::assertCount(2, $row['payments']);
|
||||
// oldest payment first
|
||||
self::assertSame('cash', $row['payments'][0]['method']);
|
||||
self::assertSame(60000, $row['payments'][0]['amount_rials']);
|
||||
self::assertSame('pos', $row['payments'][1]['method']);
|
||||
self::assertSame(40000, $row['payments'][1]['amount_rials']);
|
||||
}
|
||||
|
||||
public function testPatientInvoiceWithoutSessionHasNoPayments(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'بدون مراجعه');
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'ویزیت', 100000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
$row = $res['data']['data'][0];
|
||||
|
||||
self::assertSame([], $row['payments']);
|
||||
self::assertSame(0, $row['paid_rials']);
|
||||
self::assertSame('unsettled', $row['status']);
|
||||
}
|
||||
|
||||
public function testPatientInvoicesNotFoundForOtherTenant(): void
|
||||
{
|
||||
[, $doctor] = $this->doctor();
|
||||
|
||||
@@ -6,6 +6,8 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -45,10 +47,38 @@ class PaymentsSummaryTest extends ApiTestCase
|
||||
$ref->setValue($obj, $value);
|
||||
}
|
||||
|
||||
private function invoice(Doctor $doctor, PatientRecord $record, int $patientRials, string $status, int $issuedAt): Invoice
|
||||
/** مراجعهای با یک پرداخت نقدی ثبتشده — منبع واقعیِ «چقدر وصول شده». */
|
||||
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);
|
||||
@@ -63,10 +93,10 @@ class PaymentsSummaryTest extends ApiTestCase
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
$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);
|
||||
$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());
|
||||
@@ -83,9 +113,9 @@ class PaymentsSummaryTest extends ApiTestCase
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
$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_PAID, 1_800_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']);
|
||||
@@ -106,8 +136,8 @@ class PaymentsSummaryTest extends ApiTestCase
|
||||
$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_PAID, 1_700_000_000);
|
||||
$this->invoice($doctor, $other, 800_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
$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']);
|
||||
@@ -126,11 +156,32 @@ class PaymentsSummaryTest extends ApiTestCase
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
// the patient view sums total_rials (invoice grand total), not the patient share
|
||||
$summary = $res['data']['summary'];
|
||||
self::assertSame(3_000_000, $summary['total_rials']);
|
||||
self::assertSame(2_000_000, $summary['paid_rials']);
|
||||
self::assertSame(1_000_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_PAID, 1_700_000_000);
|
||||
$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);
|
||||
|
||||
Reference in New Issue
Block a user