add refund logic and fix books inventory logic
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s

This commit is contained in:
root
2026-08-22 13:44:25 -04:00
parent 23d1cbb64c
commit d906a915d6
45 changed files with 4711 additions and 442 deletions
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace Tests\Unit;
use App\Services\WithdrawalRefundCalculator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class WithdrawalRefundCalculatorTest extends TestCase
{
private WithdrawalRefundCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new WithdrawalRefundCalculator();
}
public function testOneWeekRetainsIssuedBooksAndOneInstructionalWeek(): void
{
$result = $this->calculator->calculate($this->baseInput([
'valid_payment_cents' => 38000,
]));
self::assertSame(210, $result['total_chargeable_days']);
self::assertSame(7, $result['studied_calendar_days']);
self::assertSame(1, $result['studied_weeks']);
self::assertSame(5000, $result['issued_book_charge_cents']);
self::assertSame(33000, $result['annual_instruction_cents']);
self::assertSame(1100, $result['earned_tuition_cents']);
self::assertSame(6100, $result['retained_charge_cents']);
self::assertSame(31900, $result['new_refund_request_cents']);
self::assertSame(0, $result['balance_due_cents']);
}
public function testPartialPaymentRefundsOnlyTheAmountAboveRetainedCharge(): void
{
$result = $this->calculator->calculate($this->baseInput([
'valid_payment_cents' => 10000,
]));
self::assertSame(3900, $result['new_refund_request_cents']);
self::assertSame(0, $result['balance_due_cents']);
}
public function testPartialPaymentBelowRetainedChargeProducesBalanceAndNoRefund(): void
{
$result = $this->calculator->calculate($this->baseInput([
'valid_payment_cents' => 5000,
]));
self::assertSame(0, $result['new_refund_request_cents']);
self::assertSame(1100, $result['balance_due_cents']);
}
public function testStartedSecondSevenDayPeriodChargesTwoWeeks(): void
{
$result = $this->calculator->calculate($this->baseInput([
'withdrawal_request_date' => '2026-08-08',
]));
self::assertSame(8, $result['studied_calendar_days']);
self::assertSame(2, $result['studied_weeks']);
self::assertSame(2200, $result['earned_tuition_cents']);
self::assertSame(7200, $result['retained_charge_cents']);
}
public function testCompletedPayoutAndReservationCannotBeRefundedAgain(): void
{
$result = $this->calculator->calculate($this->baseInput([
'valid_payment_cents' => 38000,
'completed_payout_cents' => 10000,
'open_reservation_cents' => 5000,
]));
self::assertSame(21900, $result['refundable_credit_cents']);
self::assertSame(16900, $result['new_refund_request_cents']);
}
public function testIssuedBooksCannotExceedInclusiveAnnualAllocation(): void
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->calculate($this->baseInput([
'issued_book_charge_cents' => 38001,
]));
}
public function testBooksExcludedModeIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->calculate($this->baseInput([
'annual_fee_includes_books' => false,
]));
}
public function testDayOneChargesOneStartedWeek(): void
{
$result = $this->calculator->calculate($this->baseInput([
'withdrawal_request_date' => '2026-08-01',
]));
self::assertSame(1, $result['studied_calendar_days']);
self::assertSame(1, $result['studied_weeks']);
}
public function testWithdrawalBeforeChargeStartChargesNoTuitionButRetainsBooks(): void
{
$result = $this->calculator->calculate($this->baseInput([
'enrollment_date' => '2026-08-10',
'withdrawal_request_date' => '2026-08-09',
]));
self::assertSame(0, $result['studied_calendar_days']);
self::assertSame(0, $result['studied_weeks']);
self::assertSame(5000, $result['retained_charge_cents']);
}
public function testLateEnrollmentBecomesChargeStart(): void
{
$result = $this->calculator->calculate($this->baseInput([
'enrollment_date' => '2026-08-20',
'withdrawal_request_date' => '2026-08-26',
]));
self::assertSame('2026-08-20', $result['charge_start_date']);
self::assertSame(7, $result['studied_calendar_days']);
self::assertSame(1, $result['studied_weeks']);
}
public function testEnrollmentBeforeSchoolStartsUsesSchoolStart(): void
{
$result = $this->calculator->calculate($this->baseInput([
'enrollment_date' => '2026-07-01',
'withdrawal_request_date' => '2026-08-07',
]));
self::assertSame('2026-08-01', $result['charge_start_date']);
self::assertSame(1, $result['studied_weeks']);
}
public function testDurationCapsAtTotalInstructionalWeeks(): void
{
$result = $this->calculator->calculate($this->baseInput([
'withdrawal_request_date' => '2027-12-31',
'valid_payment_cents' => 38000,
]));
self::assertSame(210, $result['studied_calendar_days']);
self::assertSame(30, $result['studied_weeks']);
self::assertSame(38000, $result['retained_charge_cents']);
self::assertSame(0, $result['new_refund_request_cents']);
}
public function testOtherChargesRemainPayable(): void
{
$result = $this->calculator->calculate($this->baseInput([
'valid_payment_cents' => 10000,
'other_charge_cents' => 2000,
]));
self::assertSame(8100, $result['retained_charge_cents']);
self::assertSame(1900, $result['new_refund_request_cents']);
}
public function testProrationRoundsOnceInCents(): void
{
$result = $this->calculator->calculate($this->baseInput([
'annual_fee_allocation_cents' => 10000,
'issued_book_charge_cents' => 0,
'total_instructional_weeks' => 3,
'withdrawal_request_date' => '2026-08-07',
]));
self::assertSame(3333, $result['earned_tuition_cents']);
}
public function testZeroTotalInstructionalWeeksIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->calculate($this->baseInput(['total_instructional_weeks' => 0]));
}
public function testMalformedDateIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->calculate($this->baseInput(['withdrawal_request_date' => '2026-02-31']));
}
public function testNoSchoolCalendarDataCannotAffectPureCalculation(): void
{
$first = $this->calculator->calculate($this->baseInput());
$second = $this->calculator->calculate($this->baseInput(['unrelated_no_school_dates' => ['2026-08-03']]));
self::assertSame($first, $second);
}
public function testNoPaymentProducesNoRefundAndFullRetainedBalance(): void
{
$result = $this->calculator->calculate($this->baseInput());
self::assertSame(0, $result['new_refund_request_cents']);
self::assertSame(6100, $result['balance_due_cents']);
}
public function testPaymentEqualToRetainedChargeProducesNeitherRefundNorBalance(): void
{
$result = $this->calculator->calculate($this->baseInput(['valid_payment_cents' => 6100]));
self::assertSame(0, $result['new_refund_request_cents']);
self::assertSame(0, $result['balance_due_cents']);
}
public function testZeroIssuedBooksProratesTheEntireAnnualAllocation(): void
{
$result = $this->calculator->calculate($this->baseInput([
'issued_book_charge_cents' => 0,
'valid_payment_cents' => 38000,
]));
self::assertSame(0, $result['issued_book_charge_cents']);
self::assertSame(1267, $result['earned_tuition_cents']);
self::assertSame(36733, $result['new_refund_request_cents']);
}
public function testFinalWeekAlwaysClosesAtTheExactAnnualAllocation(): void
{
$result = $this->calculator->calculate($this->baseInput([
'withdrawal_request_date' => '2027-02-26',
'valid_payment_cents' => 38000,
]));
self::assertSame(30, $result['studied_weeks']);
self::assertSame(33000, $result['earned_tuition_cents']);
self::assertSame(38000, $result['retained_charge_cents']);
}
public function testOpenReservationCannotMakeARefundNegative(): void
{
$result = $this->calculator->calculate($this->baseInput([
'valid_payment_cents' => 10000,
'open_reservation_cents' => 999999,
]));
self::assertSame(3900, $result['refundable_credit_cents']);
self::assertSame(0, $result['new_refund_request_cents']);
}
/** @param array<string,mixed> $overrides */
private function baseInput(array $overrides = []): array
{
return array_replace([
'annual_fee_allocation_cents' => 38000,
'issued_book_charge_cents' => 5000,
'total_instructional_weeks' => 30,
'school_year_start_date' => '2026-08-01',
'enrollment_date' => '2026-08-01',
'withdrawal_request_date' => '2026-08-07',
'valid_payment_cents' => 0,
'completed_payout_cents' => 0,
'open_reservation_cents' => 0,
'other_charge_cents' => 0,
'annual_fee_includes_books' => true,
], $overrides);
}
}
@@ -161,7 +161,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('15.00', $calculation['customer_credit']);
$this->assertSame(-3500, $calculation['rawBalanceCents']);
$this->assertSame(-1500, $calculation['rawBalanceCents']);
}
public function testFullyRefundedOverpaymentClearsCustomerCredit(): void
@@ -177,7 +177,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame(-5000, $calculation['rawBalanceCents']);
$this->assertSame(0, $calculation['rawBalanceCents']);
}
public function testPaymentAfterRefundCanRestorePaidStatus(): void
@@ -392,7 +392,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
public function testCashRefundDoesNotCreateBalanceAfterOverpaymentIsReturned(): void
public function testCashRefundAboveOverpaymentCreatesBalanceDue(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 12,
@@ -405,8 +405,8 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$calculation = $service->calculateInvoice(12);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
$this->assertSame('5.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
}
public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void
@@ -429,7 +429,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
public function testRefundedWithdrawalInvoiceHasNoBalanceDue(): void
public function testRefundedWithdrawalInvoiceShowsBalanceWhenRefundsExceedPaymentAgainstCharge(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 14,
@@ -446,7 +446,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$this->assertSame('178.00', $calculation['paid_amount']);
$this->assertSame('178.00', $calculation['refund_paid_total']);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
$this->assertSame('280.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
}
}
@@ -24,7 +24,7 @@ class RefundEligibilityServiceHarness extends RefundEligibilityService
return $this->completedPayoutCents;
}
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId, ?int $invoiceId = null): int
{
return $this->reservedAmountCents;
}
@@ -60,17 +60,17 @@ class RefundEligibilityServiceTest extends CIUnitTestCase
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
}
public function testTuitionWithdrawalAvailableCreditSubtractsCompletedPayoutsAndReservations(): void
public function testTuitionWithdrawalAvailableCreditUsesAdjustedInvoiceCreditWithoutDoubleSubtractingPayouts(): void
{
$service = new RefundEligibilityServiceHarness(20000, 2500, 1500);
$result = $service->calculateAvailableCredit(10, 20, 'tuition_withdrawal', 20);
$this->assertSame(20000, $result->sourceCreditCents);
$this->assertSame(2500, $result->completedPayoutCents);
$this->assertSame(0, $result->completedPayoutCents);
$this->assertSame(1500, $result->reservedAmountCents);
$this->assertSame(16000, $result->availableAmountCents);
$this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
$this->assertSame(18500, $result->availableAmountCents);
$this->assertNotContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
}
@@ -27,7 +27,7 @@ final class EnrollmentStatusServiceTest extends TestCase
['payment pending', 1],
['enrolled', 1],
['withdraw under review', 1],
['refund pending', 1],
['refund pending', 0],
['denied', 0],
['withdrawn', 0],
['waitlist', 0],
@@ -5,8 +5,51 @@ namespace Tests\App\Services;
use App\Services\FeeCalculationService;
use CodeIgniter\Test\CIUnitTestCase;
class FeeCalculationServiceRefundAdapterHarness extends FeeCalculationService
{
public array $previewedEnrollmentIds = [];
public function __construct(private array $latestByEnrollment, private array $previewByEnrollment)
{
}
protected function latestWithdrawalCalculation(int $enrollmentId): ?array
{
return $this->latestByEnrollment[$enrollmentId] ?? null;
}
protected function previewWithdrawalCalculation(int $enrollmentId): array
{
$this->previewedEnrollmentIds[] = $enrollmentId;
return $this->previewByEnrollment[$enrollmentId] ?? ['new_refund_request_cents' => 0];
}
}
class FeeCalculationServiceTest extends CIUnitTestCase
{
public function testRefundAdapterUsesWithdrawalFinancialPreviewAmounts(): void
{
$service = new FeeCalculationServiceRefundAdapterHarness(
latestByEnrollment: [
10 => ['status' => 'preview', 'new_refund_request_cents' => 12345],
11 => ['status' => 'superseded', 'new_refund_request_cents' => 99999],
],
previewByEnrollment: [
11 => ['status' => 'preview', 'new_refund_request_cents' => 500],
]
);
$refund = $service->calculateRefund([
['id' => 10, 'parent_id' => 8, 'enrollment_status' => 'withdraw under review'],
['id' => 11, 'parent_id' => 8, 'enrollment_status' => 'refund pending'],
['id' => 12, 'parent_id' => 8, 'enrollment_status' => 'enrolled'],
['id' => 13, 'parent_id' => 9, 'enrollment_status' => 'refund pending'],
], 8);
$this->assertSame(128.45, $refund);
$this->assertSame([11], $service->previewedEnrollmentIds);
}
public function testRefundReversesLatestTuitionTierFirst(): void
{
$fees = $this->refundFeeStack(3, 2, 380.0, 280.0);