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 $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); } }