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>, 1: array>} */ 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, ]; } }