150 lines
6.0 KiB
PHP
150 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use DateTimeImmutable;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* Pure withdrawal calculation. All monetary values are integer cents.
|
|
*
|
|
* This class deliberately has no database, session, calendar, attendance, or
|
|
* active-school-year dependencies. Callers must pass snapshotted inputs.
|
|
*/
|
|
final class WithdrawalRefundCalculator
|
|
{
|
|
/**
|
|
* @param array{
|
|
* annual_fee_allocation_cents:int,
|
|
* issued_book_charge_cents:int,
|
|
* total_instructional_weeks:int,
|
|
* school_year_start_date:string,
|
|
* enrollment_date:string,
|
|
* withdrawal_request_date:string,
|
|
* valid_payment_cents?:int,
|
|
* completed_payout_cents?:int,
|
|
* open_reservation_cents?:int,
|
|
* other_charge_cents?:int,
|
|
* annual_fee_includes_books?:bool
|
|
* } $input
|
|
* @return array<string,int|bool|string>
|
|
*/
|
|
public function calculate(array $input): array
|
|
{
|
|
$annualFee = $this->nonNegativeInt($input, 'annual_fee_allocation_cents');
|
|
$bookCharge = $this->nonNegativeInt($input, 'issued_book_charge_cents');
|
|
$totalWeeks = $this->positiveInt($input, 'total_instructional_weeks');
|
|
$validPayments = $this->optionalNonNegativeInt($input, 'valid_payment_cents');
|
|
$completedPayouts = $this->optionalNonNegativeInt($input, 'completed_payout_cents');
|
|
$openReservations = $this->optionalNonNegativeInt($input, 'open_reservation_cents');
|
|
$otherCharges = $this->optionalNonNegativeInt($input, 'other_charge_cents');
|
|
$includesBooks = (bool) ($input['annual_fee_includes_books'] ?? true);
|
|
|
|
if (! $includesBooks) {
|
|
throw new InvalidArgumentException('The active withdrawal policy requires annual tuition to include books.');
|
|
}
|
|
if ($bookCharge > $annualFee) {
|
|
throw new InvalidArgumentException('Issued-book charges exceed the student annual tuition allocation.');
|
|
}
|
|
|
|
$schoolStart = $this->date($input, 'school_year_start_date');
|
|
$enrollmentDate = $this->date($input, 'enrollment_date');
|
|
$withdrawalDate = $this->date($input, 'withdrawal_request_date');
|
|
$chargeStart = $enrollmentDate > $schoolStart ? $enrollmentDate : $schoolStart;
|
|
|
|
$totalChargeableDays = $totalWeeks * 7;
|
|
$studiedDays = 0;
|
|
if ($withdrawalDate >= $chargeStart) {
|
|
$studiedDays = ((int) $chargeStart->diff($withdrawalDate)->format('%a')) + 1;
|
|
}
|
|
$studiedDays = min($totalChargeableDays, max(0, $studiedDays));
|
|
$studiedWeeks = $studiedDays === 0
|
|
? 0
|
|
: min($totalWeeks, intdiv($studiedDays + 6, 7));
|
|
|
|
$annualInstruction = $annualFee - $bookCharge;
|
|
$earnedTuition = $this->roundRatio($annualInstruction * $studiedWeeks, $totalWeeks);
|
|
$retainedCharge = $bookCharge + $earnedTuition + $otherCharges;
|
|
|
|
$netPayments = max(0, $validPayments - $completedPayouts);
|
|
$refundableCredit = max(0, $netPayments - $retainedCharge);
|
|
$balanceDue = max(0, $retainedCharge - $netPayments);
|
|
$newRefundRequest = max(0, $refundableCredit - $openReservations);
|
|
|
|
return [
|
|
'annual_fee_includes_books' => true,
|
|
'school_year_start_date' => $schoolStart->format('Y-m-d'),
|
|
'enrollment_date' => $enrollmentDate->format('Y-m-d'),
|
|
'withdrawal_request_date' => $withdrawalDate->format('Y-m-d'),
|
|
'charge_start_date' => $chargeStart->format('Y-m-d'),
|
|
'total_instructional_weeks' => $totalWeeks,
|
|
'total_chargeable_days' => $totalChargeableDays,
|
|
'studied_calendar_days' => $studiedDays,
|
|
'studied_weeks' => $studiedWeeks,
|
|
'annual_fee_allocation_cents' => $annualFee,
|
|
'issued_book_charge_cents' => $bookCharge,
|
|
'annual_instruction_cents' => $annualInstruction,
|
|
'earned_tuition_cents' => $earnedTuition,
|
|
'other_charge_cents' => $otherCharges,
|
|
'retained_charge_cents' => $retainedCharge,
|
|
'valid_payment_cents' => $validPayments,
|
|
'completed_payout_cents' => $completedPayouts,
|
|
'net_payment_cents' => $netPayments,
|
|
'open_reservation_cents' => $openReservations,
|
|
'refundable_credit_cents' => $refundableCredit,
|
|
'new_refund_request_cents' => $newRefundRequest,
|
|
'balance_due_cents' => $balanceDue,
|
|
];
|
|
}
|
|
|
|
private function date(array $input, string $key): DateTimeImmutable
|
|
{
|
|
$raw = trim((string) ($input[$key] ?? ''));
|
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw);
|
|
$errors = DateTimeImmutable::getLastErrors();
|
|
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
|
|
throw new InvalidArgumentException($key . ' must be a valid Y-m-d date.');
|
|
}
|
|
|
|
return $date;
|
|
}
|
|
|
|
private function positiveInt(array $input, string $key): int
|
|
{
|
|
$value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT);
|
|
if ($value === false || $value <= 0) {
|
|
throw new InvalidArgumentException($key . ' must be a positive integer.');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function nonNegativeInt(array $input, string $key): int
|
|
{
|
|
$value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT);
|
|
if ($value === false || $value < 0) {
|
|
throw new InvalidArgumentException($key . ' must be a non-negative integer.');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function optionalNonNegativeInt(array $input, string $key): int
|
|
{
|
|
if (! array_key_exists($key, $input) || $input[$key] === null || $input[$key] === '') {
|
|
return 0;
|
|
}
|
|
|
|
return $this->nonNegativeInt($input, $key);
|
|
}
|
|
|
|
private function roundRatio(int $numerator, int $denominator): int
|
|
{
|
|
if ($denominator <= 0) {
|
|
throw new InvalidArgumentException('The calculation denominator must be positive.');
|
|
}
|
|
|
|
return intdiv($numerator + intdiv($denominator, 2), $denominator);
|
|
}
|
|
}
|