db->table('users') ->select('user_type, accept_school_policy, firstname, lastname, cellphone, address_street, apt, city, state, zip') ->where('id', $parentId) ->get() ->getRowArray(); if (! $userData || ($userData['user_type'] ?? '') !== 'primary') { return [ 'ok' => false, 'code' => 'invalid_user_type', 'message' => 'Invalid user type. Only primary parents can enroll.', ]; } $students = $this->db->table('students') ->where('parent_id', $parentId) ->get() ->getResultArray(); if ($students === []) { log_message('error', 'No students found for Parent ID: ' . $parentId); return [ 'ok' => false, 'code' => 'no_students', 'message' => 'No students found. Please register your child first.', ]; } $previousSchoolYear = $this->previousSchoolYearName($selectedYear); $fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear); if ($previousSchoolYear !== null) { service('enrollmentTransition')->syncParentFinancialReviewFlags( $parentId, $previousSchoolYear, $selectedYear, array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) ); } $this->attachEnrollmentDataToStudents($students, $parentId, $selectedYear, $previousSchoolYear, $fallMakeupExamOn, $isEditable); $this->ensureStudentYearStatusRows($students, $selectedYear); service('studentYearStatus')->attachToStudents($students, $selectedYear); foreach ($students as &$student) { $studentId = (int) ($student['id'] ?? 0); if ($studentId > 0 && $this->isReturningReEnrollmentStudent($studentId, $selectedYear)) { $student['is_new'] = 0; } } unset($student); if ($enrollmentStartStep < 1 || $enrollmentStartStep > 5) { $enrollmentStartStep = 0; } $preselectedStudentIds = []; if (trim($studentsParam) !== '') { $preselectedStudentIds = array_values(array_unique(array_filter( array_map('intval', explode(',', $studentsParam)), static fn (int $id): bool => $id > 0 ))); } return [ 'ok' => true, 'data' => [ 'students' => $students, 'selectedYear' => $selectedYear, 'previousSchoolYear' => $previousSchoolYear, 'fallMakeupExamOn' => $fallMakeupExamOn, 'isEditable' => $isEditable, 'withdrawalDeadline' => $withdrawalDeadline, 'lastDayOfRegistration' => $lastDayOfRegistration, 'schoolStartDate' => $schoolStartDate, 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear($parentId, $selectedYear), 'familyFinancialSummary' => $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear, $students), 'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear), 'parentContact' => $this->parentContactForEnrollment($userData), 'enrollmentStartStep' => $enrollmentStartStep, 'preselectedStudentIds' => $preselectedStudentIds, ], ]; } public function eligibilityRefreshData(int $parentId, string $selectedYear): array { $previousSchoolYear = $this->previousSchoolYearName($selectedYear); if ($previousSchoolYear === null) { return ['students' => []]; } $students = $this->db->table('students') ->select('id, firstname, lastname, dob') ->where('parent_id', $parentId) ->get() ->getResultArray(); $transitionService = service('enrollmentTransition'); $payload = []; foreach ($students as $student) { $studentId = (int) ($student['id'] ?? 0); if ($studentId <= 0) { continue; } $existingEnrollment = $this->db->table('enrollments') ->select('enrollment_status, admission_status') ->where('student_id', $studentId) ->where('school_year', $selectedYear) ->orderBy('id', 'DESC') ->get() ->getRowArray(); $existingStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['enrollment_status'] ?? '') : ''; $existingAdmissionStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['admission_status'] ?? '') : ''; if ($this->hasSettledParentEnrollmentStatus($existingStatus, $existingAdmissionStatus)) { $payload[] = [ 'student_id' => $studentId, 'student_name' => $this->studentNameFromRow($student, 'Student ID ' . $studentId), 'can_enroll' => false, 'primary_block_reason' => 'ALREADY_ENROLLED', 'primary_parent_message' => EnrollmentEligibility::alreadyEnrolledMessage($existingStatus), 'block_title' => EnrollmentEligibility::alreadyEnrolledTitle($existingStatus), 'decision' => 'ALREADY_ENROLLED', 'blocking_rule_codes' => ['ALREADY_ENROLLED'], ]; continue; } $evaluation = $transitionService->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear); $payload[] = [ 'student_id' => $studentId, 'student_name' => $this->studentNameFromRow($student, 'Student ID ' . $studentId), 'can_enroll' => (bool) ($evaluation['can_enroll'] ?? false), 'parent_enrollment_allowed' => (bool) ($evaluation['parent_enrollment_allowed'] ?? $evaluation['can_enroll'] ?? false), 'first_enrollment' => (bool) ($evaluation['first_enrollment'] ?? false), 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, 'primary_parent_message' => $evaluation['primary_parent_message'] ?? null, 'blocking_rule_codes' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])), 'decision' => $evaluation['decision'] ?? null, ]; } $financialSummary = $transitionService->getEnrollmentFinancialSummary($parentId, $previousSchoolYear, $selectedYear); $transitionService->syncParentFinancialReviewFlags( $parentId, $previousSchoolYear, $selectedYear, array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) ); return [ 'students' => $payload, 'financial_summary' => [ 'carry_forward_balance' => (float) ($financialSummary['carry_forward_balance'] ?? 0), 'current_year_balance' => (float) ($financialSummary['current_year_balance'] ?? 0), 'total_enrollment_due' => (float) ($financialSummary['total_enrollment_due'] ?? 0), ], ]; } public function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool { if ($parentId <= 0 || $schoolYear === '') { return false; } try { return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear); } catch (Throwable $e) { log_message('error', 'Failed to read parent policy acceptance: {message}', [ 'message' => $e->getMessage(), ]); return false; } } public function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source, string $ipAddress, string $userAgent): void { if ($parentId <= 0 || $schoolYear === '') { throw new \RuntimeException('Unable to record school policy acceptance.'); } if (! $this->policyAcceptanceModel->recordAcceptance($parentId, $schoolYear, $source, $ipAddress, $userAgent)) { throw new \RuntimeException('Unable to record school policy acceptance.'); } } public function updateEnrollmentStudentInfo(array $studentIds, int $parentId, array $studentInfo): array { if ($studentInfo === []) { return []; } $submittedIds = array_fill_keys(array_map('intval', $studentIds), true); $errors = []; $updates = []; foreach ($studentInfo as $studentId => $fields) { $studentId = (int) $studentId; if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) { continue; } $existing = $this->studentModel ->where('id', $studentId) ->where('parent_id', $parentId) ->first(); if (! is_array($existing)) { $errors[] = 'Student ID ' . $studentId . ': student record was not found.'; continue; } $studentLabel = trim((string) ($existing['firstname'] ?? '') . ' ' . (string) ($existing['lastname'] ?? '')) ?: 'Student ID ' . $studentId; $photoConsent = (string) ($fields['photo_consent'] ?? ''); if ($photoConsent !== '0' && $photoConsent !== '1') { $errors[] = $studentLabel . ': photo consent is required.'; continue; } $medicalConditions = $this->normalizeEnrollmentHealthSelections( $fields['medical_conditions'] ?? [], (string) ($fields['medical_condition_other'] ?? '') ); $allergies = $this->normalizeEnrollmentHealthSelections( $fields['allergies'] ?? [], (string) ($fields['allergy_other'] ?? '') ); if ($medicalConditions === []) { $errors[] = $studentLabel . ': medical conditions are required.'; continue; } if ($allergies === []) { $errors[] = $studentLabel . ': allergies are required.'; continue; } $updates[$studentId] = [ 'photo_consent' => (int) $photoConsent, 'medical_conditions' => $medicalConditions, 'allergies' => $allergies, ]; } if ($errors !== []) { return $errors; } foreach ($updates as $studentId => $payload) { if (! $this->studentModel->update($studentId, ['photo_consent' => $payload['photo_consent']])) { $errors[] = 'Student ID ' . $studentId . ': student information could not be updated.'; continue; } $this->medicalConditionModel->where('student_id', $studentId)->delete(); foreach ($payload['medical_conditions'] as $condition) { $this->medicalConditionModel->insert([ 'student_id' => $studentId, 'condition_name' => $condition, ]); } $this->allergyModel->where('student_id', $studentId)->delete(); foreach ($payload['allergies'] as $allergy) { $this->allergyModel->insert([ 'student_id' => $studentId, 'allergy' => $allergy, ]); } } return $errors; } public function updateEnrollmentParentContact(int $parentId, array $fields): array { $currentState = ''; $user = $this->userModel->find($parentId); if (is_array($user)) { $currentState = strtoupper(trim((string) ($user['state'] ?? ''))); } $normalized = $this->normalizeEnrollmentParentContact($fields, $currentState); if ($normalized['errors'] !== []) { return $normalized['errors']; } if (! $this->userModel->update($parentId, $normalized['data'])) { return ['Parent contact information could not be updated.']; } return []; } public function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array { $phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? ''; $street = $this->collapseContactWhitespace((string) ($fields['address_street'] ?? '')); $apt = $this->collapseContactWhitespace((string) ($fields['apt'] ?? '')); $city = $this->collapseContactWhitespace((string) ($fields['city'] ?? '')); $state = strtoupper(trim((string) ($fields['state'] ?? ''))); $zip = preg_replace('/\D/', '', (string) ($fields['zip'] ?? '')) ?? ''; $errors = []; $allowedStates = ['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT']; $existingState = strtoupper(trim($existingState)); if (preg_match('/^[A-Z]{2}$/', $existingState) === 1) { $allowedStates[] = $existingState; } if (strlen($phoneDigits) !== 10) { $errors[] = 'A valid 10-digit home/cell phone number is required.'; } if ($street === '' || strlen($street) < 3 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) { $errors[] = 'Home street address must be 3-50 characters and may contain only letters, numbers, spaces, periods, and hyphens.'; } if ($apt !== '' && (strlen($apt) > 15 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $apt))) { $errors[] = 'Apartment or unit must be 15 characters or fewer and may contain only letters, numbers, spaces, periods, and hyphens.'; } if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) { $errors[] = 'City must be 2-30 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.'; } if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) { $errors[] = 'State is required.'; } if (! preg_match('/^\d{5}$/', $zip)) { $errors[] = 'A valid 5-digit ZIP code is required.'; } if ($errors !== []) { return ['errors' => $errors, 'data' => []]; } $formattedPhone = (new PhoneFormatterService())->formatPhoneNumber($phoneDigits); return [ 'errors' => [], 'data' => [ 'cellphone' => $formattedPhone ?: $phoneDigits, 'address_street' => ucwords(strtolower($street)), 'apt' => strtoupper($apt), 'city' => ucwords(strtolower($city), " -'"), 'state' => $state, 'zip' => $zip, 'updated_at' => utc_now(), ], ]; } public function generateInvoiceForParentEnrollment(int $parentId, string $schoolYear, string $semester): array { if ($parentId <= 0) { return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.']; } try { $invoiceController = $this->invoiceController ??= new InvoiceController(); $result = $invoiceController->generateInvoice((string) $parentId, $schoolYear, $semester); } catch (Throwable $e) { log_message('error', 'Invoice generation failed after parent enrollment: {message}', [ 'message' => $e->getMessage(), 'parentId' => $parentId, 'schoolYear' => $schoolYear, 'semester' => $semester, ]); return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; } if (is_array($result) && ! empty($result['ok'])) { return [ 'ok' => true, 'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.', ]; } $message = is_array($result) ? (string) ($result['message'] ?? '') : ''; if ($message === 'Invoice requires at least one non-zero line.') { log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [ 'parentId' => $parentId, 'schoolYear' => $schoolYear, ]); return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.']; } log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [ 'result' => json_encode($result), 'parentId' => $parentId, 'schoolYear' => $schoolYear, 'semester' => $semester, ]); return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; } private function attachEnrollmentDataToStudents( array &$students, int $parentId, string $selectedYear, ?string $previousSchoolYear, ?string $fallMakeupExamOn, bool $isEditable ): void { $statusMap = [ 'admission under review' => 'admission under review', 'review & decision' => 'review & decision', 'payment pending' => 'payment pending', 'enrolled' => 'enrolled', 'withdraw under review' => 'withdraw under review', 'refund pending' => 'refund pending', 'withdrawn' => 'withdrawn', 'denied' => 'denied', 'waitlist' => 'waitlist', ]; foreach ($students as &$student) { $studentId = (int) ($student['id'] ?? 0); $student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear); $student['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? []; $student['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? []; $classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true); $student['class_section'] = $classSections !== [] ? implode(', ', $classSections) : 'Class not Assigned'; $enrollment = $this->db->table('enrollments') ->select('enrollment_status, admission_status') ->where('student_id', $studentId) ->where('school_year', $selectedYear) ->get() ->getRowArray(); if ($enrollment && isset($enrollment['admission_status'])) { $student['admission_status'] = $enrollment['admission_status']; $student['enrollment_status'] = $enrollment['admission_status'] === 'denied' ? 'denied' : ($statusMap[$enrollment['enrollment_status']] ?? 'not enrolled'); } else { $student['admission_status'] = null; $student['enrollment_status'] = 'not enrolled'; } $student['disable_enroll'] = in_array( $student['enrollment_status'], ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied'], true ); $decisionYear = $isEditable ? $previousSchoolYear : $selectedYear; $student['previous_year_decision'] = $decisionYear !== null ? $this->studentDecisionForYear($studentId, $decisionYear) : null; if ($isEditable) { $student['transition_evaluation'] = $previousSchoolYear !== null ? $this->transitionEvaluationForStudent($parentId, $studentId, $previousSchoolYear, $selectedYear) : null; $student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition( $student, $student['transition_evaluation'], $fallMakeupExamOn, $selectedYear ); $student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']); $student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']); $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); } else { $student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']); $student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info']; $student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned'); $student['required_action_label'] = 'Read-only closed school year.'; $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); } } unset($student); } private function parentContactForEnrollment(array $user): array { $posted = old('parent_contact'); $posted = is_array($posted) ? $posted : []; $phoneDigits = preg_replace('/\D/', '', (string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')) ?? ''; $phoneDisplay = strlen($phoneDigits) === 10 ? substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6) : trim((string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')); return [ 'firstname' => trim((string) ($user['firstname'] ?? '')), 'lastname' => trim((string) ($user['lastname'] ?? '')), 'cellphone' => $phoneDisplay, 'address_street' => trim((string) ($posted['address_street'] ?? $user['address_street'] ?? '')), 'apt' => trim((string) ($posted['apt'] ?? $user['apt'] ?? '')), 'city' => trim((string) ($posted['city'] ?? $user['city'] ?? '')), 'state' => strtoupper(trim((string) ($posted['state'] ?? $user['state'] ?? ''))), 'zip' => trim((string) ($posted['zip'] ?? $user['zip'] ?? '')), ]; } private function normalizeEnrollmentHealthSelections($selected, string $otherText): array { $values = []; foreach ((array) $selected as $value) { $value = trim((string) $value); if ($value !== '') { $values[] = $value; } } $otherText = trim($otherText); if (in_array('Other', $values, true)) { $values = array_values(array_filter($values, static fn (string $value): bool => $value !== 'Other')); if ($otherText !== '') { $values[] = mb_substr($otherText, 0, 100); } } return array_values(array_combine($values, $values) ?: []); } private function collapseContactWhitespace(string $value): string { return trim(preg_replace('/\s+/', ' ', $value) ?? ''); } private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int { $dob = trim((string) $dob); $schoolYear = trim($schoolYear); if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { return null; } try { $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); $errors = \DateTimeImmutable::getLastErrors(); $hasParseErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); if ($birthDate === false || $hasParseErrors) { return null; } $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); if ($birthDate > $schoolYearStartYearCutoff) { return null; } return $birthDate->diff($schoolYearStartYearCutoff)->y; } catch (Throwable $e) { log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ 'message' => $e->getMessage(), ]); return null; } } private function studentDecisionForYear(int $studentId, string $schoolYear): ?array { if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) { return null; } try { return $this->db->table('student_decisions') ->select('decision, source, notes, class_section_name') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray() ?: null; } catch (Throwable $e) { log_message('error', 'studentDecisionForYear failed: ' . $e->getMessage()); return null; } } private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array { try { return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent'); } catch (Throwable $e) { log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage()); return [ 'blockers' => ['Enrollment eligibility could not be evaluated. Please contact the administration.'], 'warnings' => [], 'placement_status' => 'unknown', 'deliberation_decision' => null, 'parent_enrollment_allowed' => false, 'student_self_enrollment_allowed' => false, 'assigned_grade_id' => null, 'assigned_class_section_id' => null, ]; } } private function readonlyEnrollmentEvaluation(?array $decisionRow): array { $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); return [ 'blockers' => [], 'warnings' => [], 'placement_status' => 'readonly', 'deliberation_decision' => $decision, 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? '') ?: 'Pending', 'parent_enrollment_allowed' => false, 'student_self_enrollment_allowed' => false, 'assigned_grade_id' => null, 'assigned_class_section_id' => null, ]; } private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn, string $selectedYear): array { if ($this->hasSettledParentEnrollmentStatus( (string) ($student['enrollment_status'] ?? ''), (string) ($student['admission_status'] ?? '') )) { return [ 'message' => EnrollmentEligibility::alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')), 'blocking' => true, 'level' => 'info', 'primary_block_reason' => 'ALREADY_ENROLLED', ]; } if ($evaluation === null) { return EnrollmentEligibility::parentDecisionMessage( $student, $student['previous_year_decision'] ?? null, $selectedYear, $fallMakeupExamOn ); } if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) { return ['message' => 'Enrollment has been authorized by administration.', 'blocking' => false, 'level' => 'info']; } if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) { return [ 'message' => (string) $evaluation['primary_parent_message'], 'blocking' => true, 'level' => 'danger', 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, ]; } $blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? [])))); if ($blockers !== []) { $name = $this->studentNameFromRow($student); $blockers = array_map(fn (string $message): string => $this->messageWithStudentName($name, $message), $blockers); return ['message' => implode(' ', $blockers), 'blocking' => true, 'level' => 'danger']; } $warnings = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['warnings'] ?? [])))); if ($warnings !== []) { return ['message' => implode(' ', $warnings), 'blocking' => false, 'level' => 'warning']; } if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) { $name = $this->studentNameFromRow($student); $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; return [ 'message' => $name . ' may complete enrollment now and will initially remain in the same grade until the make-up exam' . $dateText . ' is resolved by administration.', 'blocking' => false, 'level' => 'warning', ]; } return ['message' => '', 'blocking' => false, 'level' => 'info']; } private function expectedPlacementLabel(?array $evaluation): string { if ($evaluation === null || (($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id']))) { return 'Pending'; } return match ((string) ($evaluation['placement_status'] ?? '')) { 'automatic_distribution_pending' => $this->gradeLabel($this->classNameById((int) ($evaluation['assigned_grade_id'] ?? 0))), 'same_class_assigned', 'temporary_same_grade' => $this->classSectionNameById((int) ($evaluation['assigned_class_section_id'] ?? 0)) ?: 'Same grade', 'manual_class_required' => 'Same grade - administration must assign class', 'temporary_manual_class_required' => 'Same grade initially - administration must assign temporary class', 'exit_required' => 'Completion or exit process required', default => 'Pending', }; } private function requiredActionLabel(?array $evaluation): string { if ($evaluation === null) { return 'Complete re-enrollment before the registration deadline.'; } return match ($this->parentEnrollmentStateFromEvaluation($evaluation, null)) { 'Enroll' => 'Complete re-enrollment before the registration deadline.', 'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.', 'Already submitted' => 'Already submitted', 'Action needed' => 'Action needed: pay the previous-year balance or contact administration.', 'Under review' => 'Contact the school administration.', default => 'Contact administration', }; } private function parentEnrollmentState(array $student): string { $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) { return 'Already submitted'; } return $this->parentEnrollmentStateFromEvaluation( is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null, $status ); } private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string { if ($evaluation === null) { return 'Contact administration'; } $decision = (string) ($evaluation['decision'] ?? ''); $codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []); if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') { return 'Already submitted'; } if (! empty($evaluation['can_enroll']) || ! empty($evaluation['parent_enrollment_allowed']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') { return $decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== [] ? 'Eligible with follow-up' : 'Enroll'; } if ($decision === 'ELIGIBLE_WITH_WARNING') { return 'Eligible with follow-up'; } if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true) || in_array('FINANCE_APPROVAL_REQUIRED', $codes, true) || in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true) ) { return 'Action needed'; } if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [ DeliberationDecision::EXPELLED, DeliberationDecision::WITHDRAWN, DeliberationDecision::DEFERRED_DECISION, ], true)) { return 'Under review'; } return 'Contact administration'; } private function hasSettledParentEnrollmentStatus(string $status, string $admissionStatus = ''): bool { if (strtolower(trim($admissionStatus)) === 'accepted') { return true; } return in_array(strtolower(trim($status)), [ 'admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist', 'withdraw under review', 'refund pending', ], true); } private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array { $summary = service('enrollmentTransition')->getEnrollmentFinancialSummary( $parentId, $previousSchoolYear ?? '', $selectedYear, $this->enrollmentTuitionDue($students) ); $summary['policy_message'] = $this->financialPolicyMessage( (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'), (string) ($this->schoolYearConfig($selectedYear)['financial_policy_message'] ?? '') ); return $summary; } private function enrollmentTuitionDue(array $students): float { $tuitionStudents = []; $existingTuitionStudentCount = 0; foreach ($students as $student) { if ($this->countsTowardCurrentYearTuition($student)) { $existingTuitionStudentCount++; continue; } $evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : []; if (($evaluation['can_enroll'] ?? false) !== true && ($evaluation['parent_enrollment_allowed'] ?? false) !== true) { continue; } $tuitionStudents[] = [ 'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0), 'class_section_id' => (int) ($evaluation['assigned_class_section_id'] ?? $student['class_section_id'] ?? 0), ]; } if ($tuitionStudents === []) { return 0.0; } if ($existingTuitionStudentCount > 0) { return round(count($tuitionStudents) * (float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2); } return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents); } private function countsTowardCurrentYearTuition(array $student): bool { $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); if (in_array($status, ['withdrawn', 'withdraw under review', 'refund pending', 'denied', 'not enrolled'], true)) { return false; } if (in_array($status, ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist'], true)) { return true; } return strtolower(trim((string) ($student['admission_status'] ?? ''))) === 'accepted'; } private function enrollmentFeeSchedule(string $selectedYear): array { $schoolYearConfig = $this->schoolYearConfig($selectedYear); return [ 'currency' => '$', 'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2), 'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2), 'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2), ]; } private function financialPolicyMessage(string $behavior, string $configured): string { $configured = trim($configured); if ($configured !== '') { return $configured; } return match ($behavior) { 'payment_plan_required' => 'Registration may continue under an approved payment plan.', 'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.', 'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.', 'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.', default => 'The previous-year balance must be paid before registration can be submitted.', }; } private function ensureStudentYearStatusRows(array $students, string $schoolYear): void { $schoolYear = trim($schoolYear); if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { return; } $studentYearStatus = service('studentYearStatus'); foreach ($students as $student) { $studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0); if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) { continue; } $isNew = (int) ($student['is_new'] ?? 1) === 1; if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) { log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [ 'studentId' => $studentId, 'schoolYear' => $schoolYear, ]); } } } private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($studentId <= 0 || $previousSchoolYear === null) { return false; } try { foreach ([ ['student_decisions', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]], ['student_class', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]], ['enrollments', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]], ['promotion_queue', ['student_id' => $studentId, 'school_year_from' => $previousSchoolYear, 'school_year_to' => $targetSchoolYear]], ] as [$table, $where]) { if (! $this->db->tableExists($table)) { continue; } $builder = $this->db->table($table)->select('id'); foreach ($where as $column => $value) { $builder->where($column, $value); } if ($builder->limit(1)->get()->getRowArray() !== null) { return true; } } } catch (Throwable $e) { log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage()); } return false; } private function fallMakeupExamDateForYear(string $schoolYear): ?string { if ($schoolYear === '' || ! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) { return null; } try { $row = $this->db->table('school_years') ->select('fall_makeup_exam_on') ->where('name', $schoolYear) ->limit(1) ->get() ->getRowArray(); $date = trim((string) ($row['fall_makeup_exam_on'] ?? '')); return $date !== '' ? $date : null; } catch (Throwable $e) { log_message('error', 'fallMakeupExamDateForYear failed: ' . $e->getMessage()); return null; } } private function classSectionNameById(int $classSectionId): ?string { if ($classSectionId <= 0) { return null; } $row = $this->db->table('classSection') ->select('class_section_name') ->where('class_section_id', $classSectionId) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); $name = trim((string) ($row['class_section_name'] ?? '')); return $name !== '' ? $name : null; } private function classNameById(int $classId): string { if ($classId <= 0 || ! $this->db->tableExists('classes')) { return 'Assigned grade'; } $row = $this->db->table('classes') ->select('class_name') ->where('id', $classId) ->limit(1) ->get() ->getRowArray(); return trim((string) ($row['class_name'] ?? '')) ?: 'Assigned grade'; } private function gradeLabel(string $className): string { $className = trim($className); if ($className === '') { return 'Assigned grade'; } return preg_match('/^grade\b/i', $className) === 1 ? $className : 'Grade ' . $className; } private function schoolYearConfig(string $schoolYear): array { if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { return []; } return $this->db->table('school_years') ->where('name', $schoolYear) ->limit(1) ->get() ->getRowArray() ?: []; } private function previousSchoolYearName(string $schoolYear): ?string { if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { return null; } return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); } private function studentNameFromRow(array $student, string $fallback = 'The student'): string { $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); return $name !== '' ? $name : $fallback; } private function messageWithStudentName(string $studentName, string $message): string { $studentName = trim($studentName) !== '' ? trim($studentName) : 'The student'; $message = trim($message); if ($message === '' || str_contains($message, $studentName)) { return $message; } return $studentName . ': ' . $message; } }