Files
alrahma_sunday_school/app/Services/Parents/ParentEnrollmentService.php
T
root 140be9922d
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m32s
fix registration issue and split parent controller into services
2026-08-30 20:40:37 -04:00

1098 lines
45 KiB
PHP

<?php
namespace App\Services\Parents;
use App\Controllers\View\InvoiceController;
use App\Models\ConfigurationModel;
use App\Models\ParentPolicyAcceptanceModel;
use App\Models\StudentAllergyModel;
use App\Models\StudentClassModel;
use App\Models\StudentMedicalConditionModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use App\Services\FeeCalculationService;
use App\Services\PhoneFormatterService;
use App\Support\Enrollment\DeliberationDecision;
use App\Support\Enrollment\EnrollmentEligibility;
use CodeIgniter\Database\BaseConnection;
use Throwable;
class ParentEnrollmentService
{
public function __construct(
private readonly BaseConnection $db,
private readonly UserModel $userModel,
private readonly StudentModel $studentModel,
private readonly StudentClassModel $studentClassModel,
private readonly ConfigurationModel $configModel,
private readonly StudentMedicalConditionModel $medicalConditionModel,
private readonly StudentAllergyModel $allergyModel,
private readonly ParentPolicyAcceptanceModel $policyAcceptanceModel,
private ?InvoiceController $invoiceController = null,
) {
}
public function enrollClassesData(
int $parentId,
string $selectedYear,
bool $isEditable,
?string $withdrawalDeadline,
?string $lastDayOfRegistration,
?string $schoolStartDate,
int $enrollmentStartStep,
string $studentsParam
): array {
$userData = $this->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;
}
}