Files
alrahma_sunday_school/app/Services/WithdrawalRefundCalculator.php
T
root d906a915d6
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s
add refund logic and fix books inventory logic
2026-08-22 13:44:25 -04:00

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