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 { return $invoiceId === (int) ($this->invoice['id'] ?? 0) ? $this->invoice : null; } protected function calculateTuitionTotal(array $invoice): float { return $this->tuitionTotal; } protected function calculateEventTotal(array $invoice): float { return $this->eventTotal; } protected function calculateAdditionalCharges(int $invoiceId): float { return $this->additionalTotal; } protected function calculateDiscounts(int $invoiceId): float { return $this->discountTotal; } protected function calculateValidPayments(int $invoiceId): float { return $this->paidTotal; } protected function calculatePaidRefunds(int $invoiceId): float { 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([ 'id' => 10, 'invoice_number' => 'CF-20252026-20262027-P8-I4', 'total_amount' => '90.00', 'semester' => 'Opening Balance', 'description' => 'Balance carried over from previous school year 2025-2026.', ]); $calculation = $service->calculateInvoice(10); $this->assertSame('90.00', $calculation['total_amount']); $this->assertSame('90.00', $calculation['balance']); $this->assertSame(FinancialStatus::INVOICE_UNPAID, $calculation['status']); } public function testCarryForwardInvoiceIsPaidAfterFullPayment(): void { $service = new InvoiceLedgerServiceHarness([ 'id' => 10, 'invoice_number' => 'CF-20252026-20262027-P8-I4', 'total_amount' => '90.00', 'semester' => 'Opening Balance', 'description' => 'Balance carried over from previous school year 2025-2026.', ], 90.0); $calculation = $service->calculateInvoice(10); $this->assertSame('90.00', $calculation['total_amount']); $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']); } }