config->getFees(); $firstFee = (float) $fees['first_student_fee']; $secondFee = (float) $fees['second_student_fee']; $youthFee = (float) $fees['youth_fee']; foreach ($students as &$student) { $student['grade'] = $this->resolveGrade($student); $student['grade_level'] = $this->grades->getGradeLevel($student['grade']); } unset($student); usort($students, function ($a, $b) { return $this->grades->compareGrades($a['grade'] ?? '', $b['grade'] ?? ''); }); $regularCount = 0; foreach ($students as &$student) { $gradeLevel = (int) ($student['grade_level'] ?? 999); if ($gradeLevel > 9) { $student['fee_category'] = self::CATEGORY_YOUTH; $student['tuition_fee'] = $youthFee; } else { if ($regularCount === 0) { $student['fee_category'] = self::CATEGORY_FIRST_REGULAR; $student['tuition_fee'] = $firstFee; } else { $student['fee_category'] = self::CATEGORY_ADDITIONAL_REGULAR; $student['tuition_fee'] = $secondFee; } $regularCount++; } } unset($student); return $students; } public function totalTuitionFee(array $students): float { $students = $this->assignFees($students); $total = 0.0; foreach ($students as $student) { $total += (float) ($student['tuition_fee'] ?? 0); } return $total; } /** * Return a detailed student-level + family-level tuition breakdown. * * Only students that are accepted and either enrolled or payment-pending * are billed. Withdrawn students appear in the breakdown with a zero * tuition fee and the not_billable category so callers (refund service, * invoice generation) can still see them in one consistent payload. * * @return array{ * family_total: float, * billable_count: int, * non_billable_count: int, * students: array>, * fees: array * } */ public function calculateBreakdown(array $students): array { $fees = $this->config->getFees(); $billable = []; $nonBillable = []; foreach ($students as $index => $student) { $student['_original_index'] = $index; $student['grade'] = $this->resolveGrade($student); $student['grade_level'] = $this->grades->getGradeLevel($student['grade']); $student['enrollment_status'] = isset($student['enrollment_status']) ? strtolower((string) $student['enrollment_status']) : null; $student['admission_status'] = isset($student['admission_status']) ? strtolower((string) $student['admission_status']) : null; if ($this->isBillable($student)) { $billable[] = $student; } else { $nonBillable[] = $student; } } $billable = $this->assignFees($billable); $nonBillable = array_map(function (array $student): array { $student['fee_category'] = self::CATEGORY_NOT_BILLABLE; $student['tuition_fee'] = 0.0; return $student; }, $nonBillable); $combined = array_merge($billable, $nonBillable); usort($combined, fn ($a, $b) => ($a['_original_index'] ?? 0) <=> ($b['_original_index'] ?? 0)); $familyTotal = 0.0; $studentRows = []; foreach ($combined as $student) { unset($student['_original_index']); $tuitionFee = (float) ($student['tuition_fee'] ?? 0); $discount = (float) ($student['discount_amount'] ?? 0); $finalTuition = max(0.0, $tuitionFee - $discount); $familyTotal += $finalTuition; $studentRows[] = [ 'student_id' => $student['student_id'] ?? null, 'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'grade' => $student['grade'] ?? null, 'grade_level' => $student['grade_level'] ?? null, 'enrollment_status' => $student['enrollment_status'] ?? null, 'admission_status' => $student['admission_status'] ?? null, 'fee_category' => $student['fee_category'] ?? null, 'tuition_fee' => round($tuitionFee, 2), 'discount_amount' => round($discount, 2), 'final_tuition_amount' => round($finalTuition, 2), ]; } Log::info('Tuition breakdown calculated', [ 'total_students' => count($studentRows), 'billable' => count($billable), 'non_billable' => count($nonBillable), 'family_total' => round($familyTotal, 2), ]); return [ 'family_total' => round($familyTotal, 2), 'billable_count' => count($billable), 'non_billable_count' => count($nonBillable), 'students' => $studentRows, 'fees' => [ 'first_student_fee' => (float) $fees['first_student_fee'], 'second_student_fee' => (float) $fees['second_student_fee'], 'youth_fee' => (float) $fees['youth_fee'], ], ]; } public function isBillable(array $student): bool { $status = strtolower((string) ($student['enrollment_status'] ?? '')); $admission = strtolower((string) ($student['admission_status'] ?? '')); return in_array($status, self::BILLABLE_ENROLLMENT_STATUSES, true) && $admission === self::ACCEPTED_ADMISSION_STATUS; } public function isWithdrawn(array $student): bool { $status = strtolower((string) ($student['enrollment_status'] ?? '')); return in_array($status, self::WITHDRAWN_ENROLLMENT_STATUSES, true); } private function resolveGrade(array $student): string { $grade = $student['grade'] ?? null; if ($grade === null || trim((string) $grade) === '') { $sectionId = $student['class_section_id'] ?? null; $grade = $sectionId ? ClassSection::getClassSectionNameBySectionId($sectionId) : null; } return $this->grades->normalizeGrade($grade); } }