Files
alrahma_sunday_school_api/app/Services/Fees/FeeRefundCalculatorService.php
T
2026-06-08 23:45:55 -04:00

239 lines
8.5 KiB
PHP

<?php
namespace App\Services\Fees;
use App\Models\Payment;
use Illuminate\Support\Facades\Log;
class FeeRefundCalculatorService
{
public function __construct(
private FeeConfigService $config,
private FeeStudentFeeService $studentFees
) {
}
public function calculateRefund(array $students, int $parentId): array
{
$schoolYear = $this->config->getSchoolYear();
$refundDeadline = $this->config->getRefundDeadline();
$weeksOfStudy = $this->config->getWeeksOfStudy();
$schoolEndDate = $this->config->getSchoolEndDate();
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
if ($totalPaid <= 0) {
Log::info('Refund skipped: parent has no payments', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
]);
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0, []);
}
[$registered, $withdrawn] = $this->partitionStudents($students);
if (empty($withdrawn)) {
Log::info('Refund skipped: no withdrawn students found', [
'parent_id' => $parentId,
'registered_count' => count($registered),
]);
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0, []);
}
// Per the school billing plan (section 8), each student must carry the
// tuition fee that was assigned to them during fee calculation BEFORE
// the refund loop runs. We mark withdrawn vs registered before merging
// so the fee tier (first/second regular) is computed against the full
// family and we can still iterate only the withdrawn list afterwards.
foreach ($registered as &$student) {
$student['_is_withdrawn'] = false;
}
unset($student);
foreach ($withdrawn as &$student) {
$student['_is_withdrawn'] = true;
}
unset($student);
$allStudents = $this->studentFees->assignFees(array_merge($registered, $withdrawn));
$withdrawnWithFees = array_values(array_filter(
$allStudents,
fn (array $student): bool => !empty($student['_is_withdrawn'])
));
$refundAmount = 0.0;
$withdrawCount = 0;
$studentBreakdown = [];
foreach ($withdrawnWithFees as $student) {
$studentId = $student['student_id'] ?? null;
$studentFee = (float) ($student['tuition_fee'] ?? 0);
$withdrawalDate = $student['withdrawal_date'] ?? null;
$entry = [
'student_id' => $studentId,
'grade' => $student['grade'] ?? null,
'grade_level' => $student['grade_level'] ?? null,
'fee_category' => $student['fee_category'] ?? null,
'tuition_fee' => round($studentFee, 2),
'withdrawal_date' => $withdrawalDate,
'weeks_remaining' => 0,
'refund_amount' => 0.0,
'eligible' => false,
'reason' => null,
];
if (!$withdrawalDate) {
$entry['reason'] = 'missing_withdrawal_date';
Log::warning('Refund skipped for student: missing withdrawal date', [
'parent_id' => $parentId,
'student_id' => $studentId,
]);
$studentBreakdown[] = $entry;
continue;
}
if (!$refundDeadline || strtotime($withdrawalDate) > strtotime($refundDeadline)) {
$entry['reason'] = 'after_refund_deadline';
$studentBreakdown[] = $entry;
continue;
}
if (!$schoolEndDate) {
$entry['reason'] = 'missing_school_end_date';
Log::warning('Refund skipped for student: missing school end date', [
'parent_id' => $parentId,
'student_id' => $studentId,
]);
$studentBreakdown[] = $entry;
continue;
}
if ($studentFee <= 0 || $weeksOfStudy <= 0) {
$entry['reason'] = 'zero_fee_or_weeks';
Log::warning('Refund skipped for student: zero fee or weeks_of_study', [
'parent_id' => $parentId,
'student_id' => $studentId,
'student_fee' => $studentFee,
'weeks_of_study' => $weeksOfStudy,
]);
$studentBreakdown[] = $entry;
continue;
}
$weeksRemaining = $this->weeksRemaining($withdrawalDate, $schoolEndDate, $weeksOfStudy);
// Plan section 7: Refund Amount = Student Tuition Fee / Weeks of Study * Weeks Remaining
$proportionalRefund = ($studentFee / $weeksOfStudy) * $weeksRemaining;
$proportionalRefund = round(max(0.0, $proportionalRefund), 2);
$entry['weeks_remaining'] = $weeksRemaining;
$entry['refund_amount'] = $proportionalRefund;
$entry['eligible'] = $proportionalRefund > 0;
if ($entry['eligible']) {
$withdrawCount++;
$refundAmount += $proportionalRefund;
} else {
$entry['reason'] = 'no_weeks_remaining';
}
$studentBreakdown[] = $entry;
}
$uncappedRefund = $refundAmount;
if ($refundAmount > $totalPaid) {
Log::info('Refund capped at total paid', [
'parent_id' => $parentId,
'uncapped' => round($uncappedRefund, 2),
'capped' => round($totalPaid, 2),
]);
$refundAmount = $totalPaid;
// Scale per-student refunds proportionally so the breakdown
// adds up to the capped family total.
if ($uncappedRefund > 0) {
$ratio = $totalPaid / $uncappedRefund;
foreach ($studentBreakdown as &$row) {
if (!empty($row['eligible'])) {
$row['refund_amount'] = round($row['refund_amount'] * $ratio, 2);
}
}
unset($row);
}
}
Log::info('Refund calculation complete', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'withdrawn_total' => count($withdrawnWithFees),
'withdrawn_eligible' => $withdrawCount,
'refund_amount' => round($refundAmount, 2),
'total_paid' => round($totalPaid, 2),
]);
return $this->resultPayload(
$refundAmount,
$totalPaid,
$refundDeadline,
$schoolEndDate,
$weeksOfStudy,
$withdrawCount,
$studentBreakdown
);
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
*/
private function partitionStudents(array $students): array
{
$registered = [];
$withdrawn = [];
foreach ($students as $student) {
if ($this->studentFees->isWithdrawn($student)) {
$withdrawn[] = $student;
continue;
}
if ($this->studentFees->isBillable($student)) {
$registered[] = $student;
}
}
return [$registered, $withdrawn];
}
private function weeksRemaining(string $withdrawalDate, string $schoolEndDate, float $weeksOfStudy): int
{
$withdrawDateObj = new \DateTime(date('Y-m-d', strtotime($withdrawalDate)));
$schoolEndDateObj = new \DateTime($schoolEndDate);
if ($withdrawDateObj > $schoolEndDateObj) {
return 0;
}
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
return (int) min($weeksOfStudy, max(0, (int) ceil($daysRemaining / 7)));
}
private function resultPayload(
float $refund,
float $totalPaid,
?string $refundDeadline,
?string $schoolEndDate,
float $weeksOfStudy,
int $withdrawCount,
array $students
): array {
return [
'refund_amount' => round($refund, 2),
'total_paid' => round($totalPaid, 2),
'refund_deadline' => $refundDeadline,
'school_end_date' => $schoolEndDate,
'weeks_of_study' => $weeksOfStudy,
'withdrawn_students' => $withdrawCount,
'students' => $students,
];
}
}