432 lines
15 KiB
PHP
432 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\App\Libraries;
|
|
|
|
use App\Libraries\FinancialStatus;
|
|
use App\Libraries\InvoiceLedgerService;
|
|
use CodeIgniter\Test\CIUnitTestCase;
|
|
|
|
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,
|
|
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
|
|
{
|
|
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']);
|
|
}
|
|
}
|