fix financials
Tests / PHPUnit (push) Failing after 1m21s

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
@@ -0,0 +1,102 @@
<?php
namespace Tests\App\Config;
use CodeIgniter\Test\CIUnitTestCase;
class FinancialRouteIntegrityTest extends CIUnitTestCase
{
private string $routesFile;
protected function setUp(): void
{
parent::setUp();
$this->routesFile = ROOTPATH . 'app/Config/Routes.php';
}
public function testFinancialBrowserWriteRoutesArePostOnlyAndFiltered(): void
{
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
$financialWrites = array_values(array_filter($routes, static function (string $line): bool {
return str_contains($line, '$routes->post(')
&& preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $line);
}));
$this->assertNotEmpty($financialWrites);
foreach ($financialWrites as $line) {
$this->assertStringContainsString("'filter'", $line, $line);
$this->assertStringContainsString('auth:', $line, $line);
}
}
public function testFinancialStateChangingRoutesDoNotUseGet(): void
{
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach ($routes as $line) {
if (!str_contains($line, '$routes->get(')) {
continue;
}
if (! preg_match('/\$routes->get\(\s*[\'"]([^\'"]+)/', $line, $matches)) {
continue;
}
$routePath = $matches[1];
if (!preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $routePath)) {
continue;
}
$this->assertDoesNotMatchRegularExpression('/(update|store|approve|reverse|void|delete|apply|recalculate)/i', $routePath, $line);
}
}
public function testFinancialWritesDoNotUseGetPostMatchRoutes(): void
{
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
$checked = 0;
foreach ($routes as $line) {
if (!str_contains($line, '$routes->match(') || !str_contains($line, "'get'") || !str_contains($line, "'post'")) {
continue;
}
if (!preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $line)) {
continue;
}
$checked++;
$this->fail('Financial routes must split read GET and write POST handlers: ' . $line);
}
$this->assertSame(0, $checked);
}
public function testGenericApiDoesNotExposeFinancialMutations(): void
{
$text = file_get_contents($this->routesFile) ?: '';
$forbidden = [
"post('payments'",
"put('payments/",
"post('payment-transactions'",
"get('payment-transactions'",
"post('payment-notifications/send'",
"post('expenses'",
"put('expenses/",
"post('reimbursements'",
"put('reimbursements/",
"post('refunds'",
"put('refunds/",
"post('discounts/apply'",
"post('extra-charges'",
"put('extra-charges/",
"post('purchase-orders'",
"put('purchase-orders/",
"post('inventory'",
"put('inventory/",
"delete('inventory/",
];
$apiV1Start = strpos($text, "\$routes->group('api/v1'");
$this->assertIsInt($apiV1Start);
$apiV1Text = substr($text, $apiV1Start);
foreach ($forbidden as $needle) {
$this->assertStringNotContainsString($needle, $apiV1Text, $needle);
}
}
}
@@ -0,0 +1,179 @@
<?php
namespace Tests\App\Controllers\View;
use App\Controllers\View\RefundController;
use App\Libraries\RefundEligibilityService;
use CodeIgniter\Test\CIUnitTestCase;
class RefundRequestStub
{
public function __construct(private array $post = [])
{
}
public function getPost(?string $key = null)
{
if ($key === null) {
return $this->post;
}
return $this->post[$key] ?? null;
}
public function getFile(string $name)
{
return null;
}
}
class TestableRefundController extends RefundController
{
public function __construct()
{
}
public function setRequestObject($request): self
{
$this->request = $request;
return $this;
}
public function setResponseObject($response): self
{
$this->response = $response;
return $this;
}
}
class RefundEligibilityForControllerHarness extends RefundEligibilityService
{
public function __construct(private int $completedPayoutCents)
{
}
public function getCompletedPayoutTotalCentsForRefund(int $refundId): int
{
return $this->completedPayoutCents;
}
}
class RefundControllerRegressionTest extends CIUnitTestCase
{
public function testRequestRefundReadsRequestTypeFromPostBody(): void
{
$controller = $this->controllerWithPost([
'parent_id' => '123',
'amount' => '25.00',
'request_type' => 'unsupported',
]);
$response = $controller->requestRefund();
$this->assertSame(['error' => 'Invalid refund request type.'], $this->jsonResponse($response));
}
public function testPayRefundRouteArgumentIsAcceptedBeforePostRefundId(): void
{
$controller = $this->controllerWithPost([
'paid_amount' => '10.00',
'payment_method' => 'Wire',
]);
$response = $controller->payRefund(77);
$this->assertSame(['error' => 'Invalid refund payment method.'], $this->jsonResponse($response));
}
public function testCheckRefundRequiresCheckNumberBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'refund_id' => '77',
'paid_amount' => '10.00',
'payment_method' => 'Check',
]);
$response = $controller->updatePayment();
$this->assertSame(['error' => 'Check number is required for check refunds.'], $this->jsonResponse($response));
}
public function testUpdateStatusAcceptsRouteRefundId(): void
{
$controller = $this->controllerWithPost([
'status' => 'Voided',
'reason' => 'duplicate request',
]);
$response = $controller->updateStatus(77);
$this->assertSame(['error' => 'Invalid status update request.'], $this->jsonResponse($response));
}
public function testReversePayoutRequiresReasonBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'idempotency_key' => 'reverse-77',
]);
$response = $controller->reversePayout(77);
$this->assertSame(['error' => 'Reversal reason is required.'], $this->jsonResponse($response));
}
public function testReversePayoutRequiresIdempotencyKeyBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'reason' => 'wrong payout amount',
]);
$response = $controller->reversePayout(77);
$this->assertSame(['error' => 'Missing reversal idempotency key.'], $this->jsonResponse($response));
}
public function testReversePayoutRejectsNonPositiveAmountBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'reason' => 'wrong payout amount',
'idempotency_key' => 'reverse-77',
'amount' => '0.00',
]);
$response = $controller->reversePayout(77);
$this->assertSame(['error' => 'Reversal amount must be greater than zero.'], $this->jsonResponse($response));
}
public function testApprovedRefundRecalculationCannotDropBelowCompletedPayouts(): void
{
$controller = $this->controllerWithPost([]);
$property = (new \ReflectionClass(RefundController::class))->getProperty('refundEligibilityService');
$property->setAccessible(true);
$property->setValue($controller, new RefundEligibilityForControllerHarness(2500));
$method = (new \ReflectionClass(RefundController::class))->getMethod('buildRefundRecalculationUpdate');
$method->setAccessible(true);
$update = $method->invoke($controller, [
'id' => 55,
'status' => 'Approved',
], 10.00);
$this->assertEqualsWithDelta(25.0, (float)$update['refund_amount'], 0.0001);
$this->assertSame(2500, $update['approved_amount_cents']);
$this->assertSame('requires_review', $update['reconciliation_status']);
$this->assertStringContainsString('Completed payouts', $update['reconciliation_reason']);
}
private function controllerWithPost(array $post): TestableRefundController
{
return (new TestableRefundController())
->setRequestObject(new RefundRequestStub($post))
->setResponseObject(service('response'));
}
private function jsonResponse($response): array
{
return json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
}
}
@@ -68,11 +68,28 @@ class FinancialAttachmentServiceTest extends CIUnitTestCase
$file->expects($this->once())
->method('move')
->with($this->stringContains('writable/uploads/payments'), 'proof.pdf');
->with($this->stringContains('writable/uploads/_tmp/payments'), $this->stringContains('proof.pdf'))
->willReturnCallback(static function (string $dir, string $name): bool {
file_put_contents($dir . DIRECTORY_SEPARATOR . $name, 'pdf');
return true;
});
$this->assertSame('proof.pdf', $this->service->saveUploadedFile($file, 'payments'));
}
public function testDiscardStagedFileDeletesTemporaryUpload(): void
{
$tmpDir = $this->service->ensureSubdir('_tmp/checks');
$tmpPath = $tmpDir . DIRECTORY_SEPARATOR . 'pending-test.pdf';
file_put_contents($tmpPath, 'pdf');
$this->service->discardStagedFile([
'temporary_path' => $tmpPath,
]);
$this->assertFileDoesNotExist($tmpPath);
}
public function testResolvePathUsesBasenameAndReturnsNullForMissingFiles(): void
{
$dir = $this->service->ensureSubdir('receipts');
@@ -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']);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Tests\App\Libraries;
use App\Libraries\ParentLedgerService;
use App\Libraries\RefundEligibilityResult;
use CodeIgniter\Test\CIUnitTestCase;
class ParentLedgerServiceHarness extends ParentLedgerService
{
public function __construct(
private array $invoices,
private array $ledgers,
private array $eligibilities
) {
}
protected function loadInvoices(int $parentId, string $schoolYear, ?string $semester): array
{
return array_values(array_filter($this->invoices, static function (array $invoice) use ($parentId, $schoolYear, $semester): bool {
if ((int)($invoice['parent_id'] ?? 0) !== $parentId || (string)($invoice['school_year'] ?? '') !== $schoolYear) {
return false;
}
return $semester === null || $semester === '' || (string)($invoice['semester'] ?? '') === $semester;
}));
}
protected function ledgerForInvoice(int $invoiceId): array
{
return $this->ledgers[$invoiceId] ?? [];
}
protected function eligibilityForSource(
int $parentId,
?int $invoiceId,
string $sourceType,
int $sourceId
): RefundEligibilityResult {
return $this->eligibilities[$sourceId] ?? new RefundEligibilityResult(0, 0, 0, 0, []);
}
}
class ParentLedgerServiceTest extends CIUnitTestCase
{
public function testParentProjectionAggregatesLedgerAndEligibilityFields(): void
{
$service = new ParentLedgerServiceHarness(
[
['id' => 10, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Fall', 'invoice_number' => 'INV-10'],
['id' => 11, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Fall', 'invoice_number' => 'INV-11'],
['id' => 12, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Spring', 'invoice_number' => 'INV-12'],
],
[
10 => [
'totalAmountCents' => 10000,
'discountCents' => 1000,
'paidCents' => 11000,
'completedRefundCents' => 500,
'balanceDueCents' => 0,
'customerCreditCents' => 1500,
'status' => 'paid',
],
11 => [
'totalAmountCents' => 8000,
'discountCents' => 0,
'paidCents' => 2000,
'completedRefundCents' => 0,
'balanceDueCents' => 6000,
'customerCreditCents' => 0,
'status' => 'partially_paid',
],
],
[
10 => new RefundEligibilityResult(1500, 0, 300, 1200, ['HAS_APPROVED_RESERVATIONS']),
11 => new RefundEligibilityResult(0, 0, 0, 0, ['NO_AVAILABLE_CREDIT']),
]
);
$projection = $service->getParentProjection(5, '2026-2027', 'Fall');
$this->assertSame(18000, $projection['grossChargesCents']);
$this->assertSame(1000, $projection['discountCents']);
$this->assertSame(17000, $projection['netInvoiceChargesCents']);
$this->assertSame(13000, $projection['validPaymentCents']);
$this->assertSame(500, $projection['completedRefundCents']);
$this->assertSame(6000, $projection['balanceDueCents']);
$this->assertSame(1500, $projection['customerCreditCents']);
$this->assertSame(300, $projection['approvedRefundReservationCents']);
$this->assertSame(1200, $projection['availableRefundableCreditCents']);
$this->assertSame('170.00', $projection['net_invoice_charges']);
$this->assertSame('12.00', $projection['available_refundable_credit']);
$this->assertCount(2, $projection['invoices']);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Tests\App\Libraries;
use App\Libraries\RefundEligibilityService;
use CodeIgniter\Test\CIUnitTestCase;
class RefundEligibilityServiceHarness extends RefundEligibilityService
{
public function __construct(
private int $sourceCreditCents,
private int $completedPayoutCents,
private int $reservedAmountCents
) {
}
protected function calculateSourceCreditCents(int $parentId, ?int $invoiceId, string $sourceType, int $sourceId): int
{
return $this->sourceCreditCents;
}
protected function calculateCompletedPayoutCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
{
return $this->completedPayoutCents;
}
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
{
return $this->reservedAmountCents;
}
}
class RefundEligibilityServiceTest extends CIUnitTestCase
{
public function testInvoiceOverpaymentAvailableCreditDoesNotDoubleSubtractCompletedPayouts(): void
{
$service = new RefundEligibilityServiceHarness(10000, 2500, 1500);
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
$this->assertSame(10000, $result->sourceCreditCents);
$this->assertSame(0, $result->completedPayoutCents);
$this->assertSame(1500, $result->reservedAmountCents);
$this->assertSame(8500, $result->availableAmountCents);
$this->assertNotContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
}
public function testPaymentSourceAvailableCreditSubtractsCompletedPayoutsAndReservations(): void
{
$service = new RefundEligibilityServiceHarness(10000, 2500, 1500);
$result = $service->calculateAvailableCredit(10, 20, 'payment_duplicate', 77);
$this->assertSame(10000, $result->sourceCreditCents);
$this->assertSame(2500, $result->completedPayoutCents);
$this->assertSame(1500, $result->reservedAmountCents);
$this->assertSame(6000, $result->availableAmountCents);
$this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
}
public function testValidateRejectsAmountAboveAvailableCredit(): void
{
$service = new RefundEligibilityServiceHarness(10000, 7000, 1000);
$result = $service->calculateAvailableCredit(10, 20, 'payment_duplicate', 20);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Refund amount exceeds available source credit.');
$service->validateRequestedAmount($result, 2500);
}
public function testValidateRejectsNonPositiveAmount(): void
{
$service = new RefundEligibilityServiceHarness(10000, 0, 0);
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Refund amount must be greater than zero.');
$service->validateRequestedAmount($result, 0);
}
public function testZeroAvailableCreditCarriesReasonCodes(): void
{
$service = new RefundEligibilityServiceHarness(0, 0, 0);
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
$this->assertSame(0, $result->availableAmountCents);
$this->assertContains('NO_SOURCE_CREDIT', $result->reasonCodes);
$this->assertContains('NO_AVAILABLE_CREDIT', $result->reasonCodes);
}
}