configurationModel = new ConfigurationModel(); $this->enrollmentModel = new EnrollmentModel(); $this->studentClassModel = new StudentClassModel(); $this->classSectionModel = new ClassSectionModel(); $this->paymentModel = new PaymentModel(); $this->refundModel = new RefundModel(); $this->userModel = new UserModel(); $this->oldCalculator = new OldTuitionCalculatorService(); $this->newCalculator = new NewTuitionCalculatorService(); } public function calculate(string $schoolYear, string $semester, string $mode = 'compare', array $options = []): array { $schoolYear = trim($schoolYear) !== '' ? trim($schoolYear) : $this->getDefaultSchoolYear(); $semester = trim($semester) !== '' ? trim($semester) : $this->getDefaultSemester(); $mode = $this->normalizeMode($mode); $options = $this->normalizeOptions($options); $this->unitPriceOverride = $options['unit_price']; $this->youthUnitPriceOverride = $options['youth_unit_price']; $tuitionConfig = $this->getTuitionConfig(); $familyRows = []; $summary = [ 'family_count' => 0, 'student_count' => 0, 'billable_student_count' => 0, 'old_projected_tuition' => '0.00', 'new_projected_tuition' => '0.00', 'old_projected_income' => '0.00', 'new_projected_income' => '0.00', 'difference' => '0.00', 'projected_tuition' => '0.00', 'projected_income' => '0.00', 'unit_price' => '0.00', 'youth_unit_price' => '0.00', ]; $oldProjectedCents = 0; $newProjectedCents = 0; $studentCount = 0; $billableStudentCount = 0; foreach ($this->loadFamilies($schoolYear, $semester) as $family) { $parentId = (int) ($family['parent_id'] ?? 0); if ($parentId <= 0) { continue; } $studentContext = $this->loadFamilyStudents($parentId, $schoolYear, $semester, $options); $students = $studentContext['students']; $allStudents = $studentContext['all_students']; $warnings = $studentContext['warnings']; if (empty($allStudents)) { continue; } $oldResult = $this->oldCalculator->calculateFamilyTuition($students, $tuitionConfig); $newResult = $this->newCalculator->calculateFamilyTuition($students, $tuitionConfig); $oldTotalCents = $this->toCents($oldResult['total'] ?? 0); $newTotalCents = $this->toCents($newResult['total'] ?? 0); $mergedDetails = $this->mergeStudentDetails($allStudents, $oldResult['details'] ?? [], $newResult['details'] ?? []); $studentCount += count($allStudents); $billableStudentCount += count($students); $oldProjectedCents += $oldTotalCents; $newProjectedCents += $newTotalCents; $familyRows[] = [ 'parent_id' => $parentId, 'parent_name' => $family['parent_name'] ?? ('Parent #' . $parentId), 'student_count' => count($allStudents), 'billable_student_count' => count($students), 'old_total' => $this->fromCents($oldTotalCents), 'new_total' => $this->fromCents($newTotalCents), 'difference' => $this->fromCents($newTotalCents - $oldTotalCents), 'warnings' => array_values(array_unique($warnings)), 'student_details' => $mergedDetails, ]; } usort($familyRows, static fn (array $left, array $right): int => strcmp((string) ($left['parent_name'] ?? ''), (string) ($right['parent_name'] ?? ''))); $summary['family_count'] = count($familyRows); $summary['student_count'] = $studentCount; $summary['billable_student_count'] = $billableStudentCount; $summary['old_projected_tuition'] = $this->fromCents($oldProjectedCents); $summary['new_projected_tuition'] = $this->fromCents($newProjectedCents); $summary['old_projected_income'] = $summary['old_projected_tuition']; $summary['new_projected_income'] = $summary['new_projected_tuition']; $summary['difference'] = $this->fromCents($newProjectedCents - $oldProjectedCents); $summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition']; $summary['projected_income'] = $mode === 'old' ? $summary['old_projected_income'] : $summary['new_projected_income']; $summary['unit_price'] = number_format((float) ($tuitionConfig['new_tuition_full_amount'] ?? 0), 2, '.', ''); $summary['youth_unit_price'] = number_format((float) ($tuitionConfig['new_tuition_youth_amount'] ?? 0), 2, '.', ''); return [ 'school_year' => $schoolYear, 'semester' => $semester, 'calculator_mode' => $mode, 'options' => $options, 'summary' => $summary, 'families' => $familyRows, ]; } public function getAvailableSchoolYears(): array { $values = []; if ($this->enrollmentModel->db->tableExists('enrollments')) { $rows = $this->enrollmentModel->db->table('enrollments') ->select('school_year') ->where('school_year IS NOT NULL', null, false) ->where('school_year !=', '') ->groupBy('school_year') ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); foreach ($rows as $row) { $value = trim((string) ($row['school_year'] ?? '')); if ($value !== '') { $values[$value] = $value; } } } if ($this->paymentModel->db->tableExists('invoices')) { $rows = $this->paymentModel->db->table('invoices') ->select('school_year') ->where('school_year IS NOT NULL', null, false) ->where('school_year !=', '') ->groupBy('school_year') ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); foreach ($rows as $row) { $value = trim((string) ($row['school_year'] ?? '')); if ($value !== '') { $values[$value] = $value; } } } if ($values === []) { $default = $this->getDefaultSchoolYear(); if ($default !== '') { $values[$default] = $default; } } krsort($values); return array_values($values); } public function getAvailableSemesters(): array { $values = []; if ($this->enrollmentModel->db->tableExists('enrollments')) { $rows = $this->enrollmentModel->db->table('enrollments') ->select('semester') ->where('semester IS NOT NULL', null, false) ->where('semester !=', '') ->groupBy('semester') ->orderBy('semester', 'ASC') ->get() ->getResultArray(); foreach ($rows as $row) { $value = trim((string) ($row['semester'] ?? '')); if ($value !== '') { $values[$value] = $value; } } } if ($values === []) { $default = $this->getDefaultSemester(); if ($default !== '') { $values[$default] = $default; } } return array_values($values); } protected function normalizeMode(string $mode): string { $value = strtolower(trim($mode)); return in_array($value, ['old', 'new'], true) ? $value : 'compare'; } protected function normalizeOptions(array $options): array { $includeWithdrawnMode = strtolower(trim((string) ($options['include_withdrawn_mode'] ?? 'refund_deadline'))); if (!in_array($includeWithdrawnMode, ['refund_deadline', 'include', 'exclude'], true)) { $includeWithdrawnMode = 'refund_deadline'; } return [ 'include_withdrawn_mode' => $includeWithdrawnMode, 'include_payment_pending' => $this->toBool($options['include_payment_pending'] ?? true), 'include_event_only' => $this->toBool($options['include_event_only'] ?? false), 'include_paid_invoices' => $this->toBool($options['include_paid_invoices'] ?? true), 'unit_price' => $this->normalizeMoney($options['unit_price'] ?? null), 'youth_unit_price' => $this->normalizeMoney($options['youth_unit_price'] ?? null), ]; } protected function loadFamilies(string $schoolYear, string $semester): array { $builder = $this->enrollmentModel->db->table('enrollments e') ->select("e.parent_id, CONCAT(COALESCE(u.lastname, ''), ', ', COALESCE(u.firstname, '')) AS parent_name", false) ->join('users u', 'u.id = e.parent_id', 'left') ->where('e.school_year', $schoolYear) ->groupBy('e.parent_id') ->orderBy('parent_name', 'ASC'); $this->applyEnrollmentSemesterFilter($builder, 'e', $semester); return $builder->get()->getResultArray(); } protected function loadFamilyStudents(int $parentId, string $schoolYear, string $semester, array $options): array { $rows = $this->enrollmentModel->db->table('enrollments e') ->select('e.*, s.firstname, s.lastname, s.is_active') ->join('students s', 's.id = e.student_id', 'left') ->where('e.parent_id', $parentId) ->where('e.school_year', $schoolYear) ->orderBy('e.updated_at', 'DESC') ->orderBy('e.id', 'DESC') ->get() ->getResultArray(); $latestByStudent = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0 || isset($latestByStudent[$studentId])) { continue; } if (!$this->matchesEnrollmentSemester($row, $semester)) { continue; } $latestByStudent[$studentId] = $row; } ksort($latestByStudent); $withinRefundWindow = $this->isWithinRefundWindow((string) ($this->configurationModel->getConfig('refund_deadline') ?? '')); $students = []; $allStudents = []; $warnings = []; $eventOnlyCount = 0; $missingClassCount = 0; $inactiveCount = 0; $pendingSkippedCount = 0; foreach ($latestByStudent as $row) { $studentId = (int) ($row['student_id'] ?? 0); $studentName = trim(((string) ($row['firstname'] ?? '')) . ' ' . ((string) ($row['lastname'] ?? ''))); $studentLabel = $studentName !== '' ? $studentName : ('Student #' . $studentId); if ((int) ($row['is_active'] ?? 1) === 0) { $inactiveCount++; $allStudents[] = [ 'student_id' => $studentId, 'student_name' => $studentLabel, 'grade_level' => 'N/A', 'billable' => false, 'excluded_reason' => 'inactive', ]; continue; } $inclusion = $this->resolveEnrollmentInclusion($row, $options, $withinRefundWindow); if (!$inclusion['include']) { if (($inclusion['reason'] ?? '') === 'payment_pending_excluded') { $pendingSkippedCount++; } continue; } $classFlags = $this->resolveStudentClassFlags($studentId, $schoolYear); $gradeName = $this->resolveGradeName($studentId, $schoolYear, $row['class_section_id'] ?? null); $studentBase = [ 'student_id' => $studentId, 'student_name' => $studentLabel, 'grade_level' => $gradeName, 'billable' => false, 'excluded_reason' => null, ]; if (!$classFlags['has_any_assignment']) { $missingClassCount++; $studentBase['excluded_reason'] = 'missing_class_assignment'; $allStudents[] = $studentBase; continue; } if (!$classFlags['has_non_event_assignment']) { $eventOnlyCount++; $studentBase['excluded_reason'] = 'event_only'; $allStudents[] = $studentBase; if ($options['include_event_only']) { $warnings[] = $studentLabel . ' is event-only and excluded from tuition billing.'; } continue; } if ($gradeName === 'N/A') { $warnings[] = $studentLabel . ' has no resolved class section grade.'; } $studentBase['billable'] = true; $allStudents[] = $studentBase; $students[] = [ 'student_id' => $studentId, 'student_name' => $studentLabel, 'grade_level' => $gradeName, ]; } if ($eventOnlyCount > 0) { $warnings[] = $eventOnlyCount . ' event-only student(s) excluded from tuition.'; } if ($missingClassCount > 0) { $warnings[] = $missingClassCount . ' student(s) missing class assignments.'; } if ($inactiveCount > 0) { $warnings[] = $inactiveCount . ' inactive student(s) skipped.'; } if ($pendingSkippedCount > 0) { $warnings[] = $pendingSkippedCount . ' payment-pending student(s) skipped by filter.'; } if ($this->toCents($this->configurationModel->getConfig('new_tuition_full_amount') ?? 0) <= 0) { $warnings[] = 'New tuition full amount is missing or zero.'; } return [ 'students' => $students, 'all_students' => $allStudents, 'warnings' => array_values(array_unique($warnings)), ]; } protected function resolveEnrollmentInclusion(array $row, array $options, bool $withinRefundWindow): array { $admissionStatus = strtolower(trim((string) ($row['admission_status'] ?? ''))); $enrollmentStatus = strtolower(trim((string) ($row['enrollment_status'] ?? ''))); if ($admissionStatus === 'denied' || $enrollmentStatus === 'admission under review') { return ['include' => false, 'reason' => 'not_admitted']; } if ($enrollmentStatus === 'payment pending') { return ['include' => $options['include_payment_pending'], 'reason' => 'payment_pending_excluded']; } if ($enrollmentStatus === 'enrolled') { return ['include' => true, 'reason' => null]; } if (in_array($enrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true)) { if ($options['include_withdrawn_mode'] === 'include') { return ['include' => true, 'reason' => null]; } if ($options['include_withdrawn_mode'] === 'exclude') { return ['include' => false, 'reason' => 'withdrawn_excluded']; } return ['include' => !$withinRefundWindow, 'reason' => 'refund_deadline_excluded']; } return ['include' => $admissionStatus === 'accepted', 'reason' => 'accepted_only']; } protected function resolveStudentClassFlags(int $studentId, string $schoolYear): array { $rows = $this->studentClassModel->where('student_id', $studentId) ->where('school_year', $schoolYear) ->findAll(); $hasAnyAssignment = !empty($rows); $hasNonEventAssignment = false; $hasEventOnlyAssignment = false; foreach ($rows as $row) { $isEventOnly = (int) ($row['is_event_only'] ?? 0) === 1; $hasEventOnlyAssignment = $hasEventOnlyAssignment || $isEventOnly; $hasNonEventAssignment = $hasNonEventAssignment || !$isEventOnly; } return [ 'has_any_assignment' => $hasAnyAssignment, 'has_non_event_assignment' => $hasNonEventAssignment, 'has_event_only_assignment' => $hasEventOnlyAssignment, ]; } protected function resolveGradeName(int $studentId, string $schoolYear, $classSectionId = null): string { $sectionId = $classSectionId; if (empty($sectionId)) { $row = $this->studentClassModel ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('is_event_only', 0) ->orderBy('updated_at', 'DESC') ->first(); $sectionId = $row['class_section_id'] ?? null; } if (empty($sectionId)) { return 'N/A'; } $name = $this->classSectionModel->getClassSectionNameBySectionId($sectionId); return is_string($name) && trim($name) !== '' ? strtoupper(trim($name)) : 'N/A'; } protected function calculateAlreadyCollected(int $parentId, string $schoolYear, string $semester): int { $paymentBuilder = $this->paymentModel->db->table('payments') ->select('COALESCE(SUM(paid_amount),0) AS total_amount') ->where('parent_id', $parentId) ->where('school_year', $schoolYear); if ($semester !== '') { $paymentBuilder->where('semester', $semester); } if ($this->paymentModel->db->fieldExists('status', 'payments')) { $paymentBuilder->groupStart() ->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($this->paymentModel->db->fieldExists('is_void', 'payments')) { $paymentBuilder->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $paymentRow = $paymentBuilder->get()->getRowArray(); $paidCents = $this->toCents($paymentRow['total_amount'] ?? 0); $refundBuilder = $this->refundModel->db->table('refunds') ->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->whereIn('status', FinancialStatus::REFUND_REDUCES_INVOICE_STATUSES); if ($semester !== '' && $this->refundModel->db->fieldExists('semester', 'refunds')) { $refundBuilder->where('semester', $semester); } $refundRow = $refundBuilder->get()->getRowArray(); $refundCents = $this->toCents($refundRow['total_amount'] ?? 0); return max(0, $paidCents - $refundCents); } protected function mergeStudentDetails(array $allStudents, array $oldDetails, array $newDetails): array { $oldMap = []; foreach ($oldDetails as $detail) { $oldMap[(int) ($detail['student_id'] ?? 0)] = $detail; } $newMap = []; foreach ($newDetails as $detail) { $newMap[(int) ($detail['student_id'] ?? 0)] = $detail; } $rows = []; foreach ($allStudents as $student) { $studentId = (int) ($student['student_id'] ?? 0); $old = $oldMap[$studentId] ?? null; $new = $newMap[$studentId] ?? null; $rows[] = [ 'student_id' => $studentId, 'student_name' => $student['student_name'] ?? '', 'grade_level' => $student['grade_level'] ?? 'N/A', 'billable' => (bool) ($student['billable'] ?? false), 'excluded_reason' => $student['excluded_reason'] ?? null, 'old_rule' => $old['rule'] ?? null, 'old_amount' => $old['amount'] ?? '0.00', 'new_rule' => $new['rule'] ?? null, 'new_amount' => $new['amount'] ?? '0.00', ]; } return $rows; } protected function getTuitionConfig(): array { return [ 'grade_fee' => $this->configurationModel->getConfig('grade_fee'), 'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'), 'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'), 'youth_fee' => $this->configurationModel->getConfig('youth_fee') ?? '200.00', 'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(), 'new_tuition_youth_amount' => $this->normalizedYouthUnitPriceOverride(), 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'), 'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'), 'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'), ]; } protected ?string $unitPriceOverride = null; protected ?string $youthUnitPriceOverride = null; protected function normalizedUnitPriceOverride(): string { return $this->unitPriceOverride ?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00'); } protected function normalizedYouthUnitPriceOverride(): string { return $this->youthUnitPriceOverride ?? (string) ( $this->configurationModel->getConfig('new_tuition_youth_amount') ?? $this->configurationModel->getConfig('youth_fee') ?? '200.00' ); } protected function normalizeMoney($value): ?string { if ($value === null || trim((string) $value) === '') { return null; } $normalized = preg_replace('/[^0-9.]+/', '', (string) $value); if ($normalized === '' || !is_numeric($normalized)) { return null; } $amount = (float) $normalized; return $amount > 0 ? number_format($amount, 2, '.', '') : null; } protected function isWithinRefundWindow(string $refundDeadline): bool { if ($refundDeadline === '') { return true; } try { $timeZone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); $today = new \DateTimeImmutable('today', $timeZone); $deadline = new \DateTimeImmutable($refundDeadline, $timeZone); return $today <= $deadline; } catch (\Throwable $e) { return true; } } protected function getDefaultSchoolYear(): string { return (string) ($this->configurationModel->getConfig('school_year') ?? ''); } protected function getDefaultSemester(): string { return ''; } protected function applyEnrollmentSemesterFilter($builder, string $alias, string $semester): void { if ($semester === '') { return; } $builder->groupStart() ->where($alias . '.semester', $semester) ->orWhere($alias . '.semester', '') ->orWhere($alias . '.semester IS NULL', null, false) ->groupEnd(); } protected function matchesEnrollmentSemester(array $row, string $semester): bool { if ($semester === '') { return true; } $value = trim((string) ($row['semester'] ?? '')); return $value === '' || strcasecmp($value, $semester) === 0; } protected function toBool($value): bool { if (is_bool($value)) { return $value; } $normalized = strtolower(trim((string) $value)); return in_array($normalized, ['1', 'true', 'yes', 'on'], true); } protected function toCents($amount): int { return (int) round(((float) $amount) * 100); } protected function fromCents(int $cents): string { return number_format($cents / 100, 2, '.', ''); } }