@@ -10,11 +10,32 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
||||
{
|
||||
private array $invoice;
|
||||
private float $paidTotal;
|
||||
private float $refundPaidTotal;
|
||||
private float $tuitionTotal;
|
||||
private float $eventTotal;
|
||||
private float $additionalTotal;
|
||||
private float $discountTotal;
|
||||
private ?array $frozenTotals;
|
||||
|
||||
public function __construct(array $invoice, float $paidTotal = 0.0)
|
||||
public function __construct(
|
||||
array $invoice,
|
||||
float $paidTotal = 0.0,
|
||||
float $refundPaidTotal = 0.0,
|
||||
float $tuitionTotal = 0.0,
|
||||
float $eventTotal = 0.0,
|
||||
float $additionalTotal = 0.0,
|
||||
float $discountTotal = 0.0,
|
||||
?array $frozenTotals = null
|
||||
)
|
||||
{
|
||||
$this->invoice = $invoice;
|
||||
$this->paidTotal = $paidTotal;
|
||||
$this->refundPaidTotal = $refundPaidTotal;
|
||||
$this->tuitionTotal = $tuitionTotal;
|
||||
$this->eventTotal = $eventTotal;
|
||||
$this->additionalTotal = $additionalTotal;
|
||||
$this->discountTotal = $discountTotal;
|
||||
$this->frozenTotals = $frozenTotals;
|
||||
}
|
||||
|
||||
protected function loadInvoice(int $invoiceId): ?array
|
||||
@@ -24,22 +45,22 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
||||
|
||||
protected function calculateTuitionTotal(array $invoice): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->tuitionTotal;
|
||||
}
|
||||
|
||||
protected function calculateEventTotal(array $invoice): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->eventTotal;
|
||||
}
|
||||
|
||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->additionalTotal;
|
||||
}
|
||||
|
||||
protected function calculateDiscounts(int $invoiceId): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->discountTotal;
|
||||
}
|
||||
|
||||
protected function calculateValidPayments(int $invoiceId): float
|
||||
@@ -49,12 +70,275 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
||||
|
||||
protected function calculatePaidRefunds(int $invoiceId): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->refundPaidTotal;
|
||||
}
|
||||
|
||||
protected function calculateFrozenLineTotals(int $invoiceId): ?array
|
||||
{
|
||||
return $this->frozenTotals;
|
||||
}
|
||||
}
|
||||
|
||||
class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testInvoiceWithNoPaymentHasFullBalanceDue(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 20,
|
||||
'invoice_number' => 'INV-2026-00020',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 0.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(20);
|
||||
|
||||
$this->assertSame('100.00', $calculation['total_amount']);
|
||||
$this->assertSame(10000, $calculation['totalAmountCents']);
|
||||
$this->assertSame(10000, $calculation['balanceDueCents']);
|
||||
$this->assertSame(0, $calculation['customerCreditCents']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_UNPAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testPartiallyPaidInvoiceHasRemainingBalance(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 21,
|
||||
'invoice_number' => 'INV-2026-00021',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 40.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(21);
|
||||
|
||||
$this->assertSame('60.00', $calculation['balance']);
|
||||
$this->assertSame(6000, $calculation['rawBalanceCents']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testFullyPaidInvoiceHasNoBalanceOrCredit(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 22,
|
||||
'invoice_number' => 'INV-2026-00022',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 100.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(22);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testOverpaidInvoiceExposesCustomerCreditOnly(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 23,
|
||||
'invoice_number' => 'INV-2026-00023',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 125.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(23);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('25.00', $calculation['customer_credit']);
|
||||
$this->assertSame(-2500, $calculation['rawBalanceCents']);
|
||||
$this->assertSame(2500, $calculation['customerCreditCents']);
|
||||
}
|
||||
|
||||
public function testPartiallyRefundedInvoiceUsesRefundAsCashOut(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 24,
|
||||
'invoice_number' => 'INV-2026-00024',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 125.0, 10.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(24);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('15.00', $calculation['customer_credit']);
|
||||
$this->assertSame(-1500, $calculation['rawBalanceCents']);
|
||||
}
|
||||
|
||||
public function testFullyRefundedOverpaymentClearsCustomerCredit(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 25,
|
||||
'invoice_number' => 'INV-2026-00025',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 125.0, 25.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(25);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(0, $calculation['rawBalanceCents']);
|
||||
}
|
||||
|
||||
public function testPaymentAfterRefundCanRestorePaidStatus(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 26,
|
||||
'invoice_number' => 'INV-2026-00026',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 130.0, 30.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(26);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testGeneratedTwiceWithSameSourceDataReturnsIdenticalLedger(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 27,
|
||||
'invoice_number' => 'INV-2026-00027',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
$first = (new InvoiceLedgerServiceHarness($invoice, 55.0, 5.0, 100.0, 20.0, 10.0, 15.0))->calculateInvoice(27);
|
||||
$second = (new InvoiceLedgerServiceHarness($invoice, 55.0, 5.0, 100.0, 20.0, 10.0, 15.0))->calculateInvoice(27);
|
||||
|
||||
$this->assertSame($first, $second);
|
||||
}
|
||||
|
||||
public function testFrozenInvoiceLinesOverrideChangedLiveSources(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 28,
|
||||
'invoice_number' => 'INV-2026-00028',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$service = new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
paidTotal: 0.0,
|
||||
refundPaidTotal: 0.0,
|
||||
tuitionTotal: 999.0,
|
||||
eventTotal: 888.0,
|
||||
additionalTotal: 777.0,
|
||||
discountTotal: 0.0,
|
||||
frozenTotals: [
|
||||
'tuition_cents' => 10000,
|
||||
'event_cents' => 2000,
|
||||
'additional_cents' => 500,
|
||||
'total_cents' => 12500,
|
||||
]
|
||||
);
|
||||
|
||||
$calculation = $service->calculateInvoice(28);
|
||||
|
||||
$this->assertSame('100.00', $calculation['tuition_total']);
|
||||
$this->assertSame('20.00', $calculation['event_total']);
|
||||
$this->assertSame('5.00', $calculation['additional_total']);
|
||||
$this->assertSame('125.00', $calculation['total_amount']);
|
||||
$this->assertSame('125.00', $calculation['balance']);
|
||||
}
|
||||
|
||||
public function testFrozenInvoiceRecalculationIsStableAfterPayment(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 29,
|
||||
'invoice_number' => 'INV-2026-00029',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$frozenTotals = [
|
||||
'tuition_cents' => 10000,
|
||||
'event_cents' => 0,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 10000,
|
||||
];
|
||||
|
||||
$before = (new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
paidTotal: 0.0,
|
||||
tuitionTotal: 400.0,
|
||||
frozenTotals: $frozenTotals
|
||||
))->calculateInvoice(29);
|
||||
|
||||
$after = (new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
paidTotal: 25.0,
|
||||
tuitionTotal: 600.0,
|
||||
frozenTotals: $frozenTotals
|
||||
))->calculateInvoice(29);
|
||||
|
||||
$this->assertSame('100.00', $before['total_amount']);
|
||||
$this->assertSame('100.00', $after['total_amount']);
|
||||
$this->assertSame('75.00', $after['balance']);
|
||||
}
|
||||
|
||||
public function testFrozenInvoiceExposesEligibleDiscountBaseFromLines(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 30,
|
||||
'invoice_number' => 'INV-2026-00030',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$service = new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
discountTotal: 50.0,
|
||||
frozenTotals: [
|
||||
'tuition_cents' => 10000,
|
||||
'event_cents' => 3000,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 13000,
|
||||
'discount_eligible_base_cents' => 10000,
|
||||
]
|
||||
);
|
||||
|
||||
$calculation = $service->calculateInvoice(30);
|
||||
|
||||
$this->assertSame(13000, $calculation['gross_charge_cents']);
|
||||
$this->assertSame(10000, $calculation['discount_eligible_base_cents']);
|
||||
$this->assertSame(5000, $calculation['requested_discount_cents']);
|
||||
$this->assertSame(5000, $calculation['applied_discount_cents']);
|
||||
$this->assertSame(8000, $calculation['net_charge_cents']);
|
||||
}
|
||||
|
||||
public function testEventOnlyFrozenInvoiceReceivesNoDiscount(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 31,
|
||||
'invoice_number' => 'INV-2026-00031',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$service = new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
discountTotal: 25.0,
|
||||
frozenTotals: [
|
||||
'tuition_cents' => 0,
|
||||
'event_cents' => 7500,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 7500,
|
||||
'discount_eligible_base_cents' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
$calculation = $service->calculateInvoice(31);
|
||||
|
||||
$this->assertSame(0, $calculation['discount_eligible_base_cents']);
|
||||
$this->assertSame(2500, $calculation['requested_discount_cents']);
|
||||
$this->assertSame(0, $calculation['applied_discount_cents']);
|
||||
$this->assertSame(7500, $calculation['net_charge_cents']);
|
||||
$this->assertSame('75.00', $calculation['balance']);
|
||||
}
|
||||
|
||||
public function testCarryForwardInvoiceUsesStoredOpeningBalance(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
@@ -88,4 +372,60 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testCashRefundReducesCustomerCreditWithoutIncreasingOverpayment(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 11,
|
||||
'invoice_number' => 'CF-20252026-20262027-P8-I5',
|
||||
'total_amount' => '100.00',
|
||||
'semester' => 'Opening Balance',
|
||||
'description' => 'Balance carried over from previous school year 2025-2026.',
|
||||
], 125.0, 20.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(11);
|
||||
|
||||
$this->assertSame('100.00', $calculation['total_amount']);
|
||||
$this->assertSame('20.00', $calculation['refund_paid_total']);
|
||||
$this->assertSame('5.00', $calculation['customer_credit']);
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testCashRefundCanRestoreBalanceAfterOverpaymentIsReturned(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 12,
|
||||
'invoice_number' => 'CF-20252026-20262027-P8-I6',
|
||||
'total_amount' => '100.00',
|
||||
'semester' => 'Opening Balance',
|
||||
'description' => 'Balance carried over from previous school year 2025-2026.',
|
||||
], 125.0, 30.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(12);
|
||||
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame('5.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 13,
|
||||
'invoice_number' => 'INV-2026-00013',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
'description' => 'Current year tuition invoice.',
|
||||
], 120.0, 15.0, 100.0, 0.0, 0.0, 10.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(13);
|
||||
|
||||
$this->assertSame('100.00', $calculation['total_amount']);
|
||||
$this->assertSame('10.00', $calculation['discount_total']);
|
||||
$this->assertSame('15.00', $calculation['refund_paid_total']);
|
||||
$this->assertSame('15.00', $calculation['customer_credit']);
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user