fix enrollment, invoice, payment and financila aid
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-24 21:20:58 -04:00
parent 576da3bd78
commit 608aca79b8
30 changed files with 3327 additions and 774 deletions
+271 -119
View File
@@ -317,13 +317,8 @@ class ParentController extends BaseController
$student['class_section'] = !empty($classSections)
? implode(', ', $classSections)
: 'Class not Assigned';
$isArabicClass = !empty($classSections) && array_reduce(
$classSections,
static fn($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0),
false
);
// Get enrollment status AND admission status
// Get enrollment status AND admission status
$enrollment = $this->db->table('enrollments')
->select('enrollment_status, admission_status')
->where('student_id', $studentId)
@@ -350,10 +345,7 @@ class ParentController extends BaseController
$student['enrollment_status'] = 'not enrolled';
}
// If assigned to Arabic class without an enrollment record, display as enrolled.
if ($student['enrollment_status'] === 'not enrolled' && $isArabicClass) {
$student['enrollment_status'] = 'enrolled';
}
// No enrollment record fabrication from class assignment.
// ✅ Updated disable logic to include denied status
$student['disable_enroll'] = in_array(
@@ -411,6 +403,15 @@ class ParentController extends BaseController
)));
}
if ($previousSchoolYear !== null) {
service('enrollmentTransition')->syncParentFinancialReviewFlags(
(int) $parentId,
$previousSchoolYear,
$selectedYear,
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
);
}
// Render view
return view('/parent/enroll_classes', [
'students' => $students,
@@ -528,6 +529,7 @@ class ParentController extends BaseController
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if (empty($evaluation['can_enroll'])) {
$transitionService->logEnrollmentBlock($evaluation, 'parent_enroll_submit', (int) $parentId, (int) $parentId);
$messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
$codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? []))));
$errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.');
@@ -891,7 +893,12 @@ class ParentController extends BaseController
private function updateEnrollmentParentContact(int $parentId): array
{
$fields = $this->request->getPost('parent_contact');
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : []);
$currentState = '';
$user = $this->userModel->find($parentId);
if (is_array($user)) {
$currentState = strtoupper(trim((string) ($user['state'] ?? '')));
}
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : [], $currentState);
if ($normalized['errors'] !== []) {
return $normalized['errors'];
}
@@ -907,33 +914,38 @@ class ParentController extends BaseController
* @param array<string, mixed> $fields
* @return array{errors: list<string>, data: array<string, string>}
*/
private function normalizeEnrollmentParentContact(array $fields): array
private function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array
{
$phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? '';
$street = trim((string) ($fields['address_street'] ?? ''));
$apt = trim((string) ($fields['apt'] ?? ''));
$city = trim((string) ($fields['city'] ?? ''));
$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 = trim((string) ($fields['zip'] ?? ''));
$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 (strlen($street) < 5 || strlen($street) > 255) {
$errors[] = 'Home street address is required.';
if ($street === '' || strlen($street) < 2 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) {
$errors[] = 'Home street address must be 250 characters and may contain only letters, numbers, spaces, periods, and hyphens.';
}
if ($apt !== '' && strlen($apt) > 15) {
$errors[] = 'Apartment or unit must be 15 characters or fewer.';
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) > 100) {
$errors[] = 'City is required.';
if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) {
$errors[] = 'City must be 230 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.';
}
if (! preg_match('/^[A-Z]{2}$/', $state)) {
if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) {
$errors[] = 'State is required.';
}
@@ -951,9 +963,9 @@ class ParentController extends BaseController
'errors' => [],
'data' => [
'cellphone' => $formattedPhone ?: $phoneDigits,
'address_street' => $street,
'apt' => $apt,
'city' => ucfirst(strtolower($city)),
'address_street' => ucwords(strtolower($street)),
'apt' => strtoupper($apt),
'city' => ucwords(strtolower($city), " -'"),
'state' => $state,
'zip' => $zip,
'updated_at' => utc_now(),
@@ -961,6 +973,11 @@ class ParentController extends BaseController
];
}
private function collapseContactWhitespace(string $value): string
{
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
/**
* @param mixed $selected
* @return list<string>
@@ -1211,51 +1228,6 @@ class ParentController extends BaseController
}
}
private function studentPassedPreviousYear(int $studentId, string $targetSchoolYear): bool
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($studentId <= 0 || $previousSchoolYear === null) {
return false;
}
try {
if ($this->db->tableExists('promotion_queue')) {
$queuedPromotion = $this->db->table('promotion_queue')
->select('id')
->where('student_id', $studentId)
->where('school_year_from', $previousSchoolYear)
->where('school_year_to', $targetSchoolYear)
->whereIn('status', ['queued', 'assigned', 'applied'])
->limit(1)
->get()
->getRowArray();
if ($queuedPromotion !== null) {
return true;
}
}
if ($this->db->tableExists('student_decisions')) {
$decisionRow = $this->db->table('student_decisions')
->select('decision')
->where('student_id', $studentId)
->where('school_year', $previousSchoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::PASSED;
}
} catch (\Throwable $e) {
log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage());
}
return false;
}
private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
@@ -1704,6 +1676,18 @@ class ParentController extends BaseController
private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): 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 $this->enrollmentEligibilityMessageForStudent(
$student,
@@ -1721,6 +1705,15 @@ class ParentController extends BaseController
];
}
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);
@@ -1830,14 +1823,7 @@ class ParentController extends BaseController
private function parentEnrollmentState(array $student): string
{
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
if (in_array($status, [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
], true)) {
if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) {
return 'Already submitted';
}
@@ -1889,29 +1875,131 @@ class ParentController extends BaseController
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
{
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
$carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0;
$currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear);
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
$tuitionDue = $this->enrollmentTuitionDue($students);
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
$amountDue = max(0.0, $carryOver) + max(0.0, $currentBalance) + $registrationFee + $tuitionDue + $mandatoryFees;
$summary = service('enrollmentTransition')->getEnrollmentFinancialSummary(
$parentId,
$previousSchoolYear ?? '',
$selectedYear,
$tuitionDue
);
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
$summary['policy_message'] = $this->financialPolicyMessage(
$behavior,
(string) ($schoolYearConfig['financial_policy_message'] ?? '')
);
return [
'currency' => '$',
'carry_over_balance' => round($carryOver, 2),
'current_balance' => round($currentBalance, 2),
'registration_fee' => $registrationFee,
'tuition_due_at_registration' => $tuitionDue,
'mandatory_fees' => $mandatoryFees,
'amount_due' => round($amountDue, 2),
'balance_behavior' => $behavior,
'payment_plan_available' => (bool) ($schoolYearConfig['payment_plan_available'] ?? false),
'policy_message' => $this->financialPolicyMessage($behavior, (string) ($schoolYearConfig['financial_policy_message'] ?? '')),
];
return $summary;
}
public function enrollmentEligibilityRefresh()
{
$parentId = (int) session()->get('user_id');
if ($parentId <= 0) {
return $this->response->setStatusCode(401)->setJSON(['error' => 'Unauthorized']);
}
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
if ($previousSchoolYear === null) {
return $this->response->setJSON(['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,
'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,
'can_enroll' => (bool) ($evaluation['can_enroll'] ?? 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,
(string) $previousSchoolYear,
$selectedYear
);
if ($previousSchoolYear !== null) {
$transitionService->syncParentFinancialReviewFlags(
$parentId,
$previousSchoolYear,
$selectedYear,
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
);
}
return $this->response->setJSON([
'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),
],
]);
}
private function enrollmentTuitionDue(array $students): float
@@ -1919,16 +2007,11 @@ class ParentController extends BaseController
$tuitionStudents = [];
foreach ($students as $student) {
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null)
? $student['enrollment_eligibility_message']
: ['blocking' => false];
if ($status !== 'not enrolled' || ! empty($eligibilityMessage['blocking'])) {
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
if (($evaluation['can_enroll'] ?? false) !== true) {
continue;
}
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
$tuitionStudents[] = [
'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0),
'class_section_id' => (int) (
@@ -1954,9 +2037,7 @@ class ParentController extends BaseController
'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),
'registration_fee' => round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2),
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
'mandatory_fees' => round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2),
];
}
@@ -1976,20 +2057,79 @@ class ParentController extends BaseController
};
}
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
{
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
return 0.0;
if (! $this->db->tableExists('enrollment_transition_audits')) {
return;
}
$row = $this->db->table('invoices')
->select('COALESCE(SUM(balance), 0) AS balance', false)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->getRowArray();
$studentId = (int) ($original['id'] ?? 0);
if ($studentId <= 0) {
return;
}
return round((float) ($row['balance'] ?? 0), 2);
$fields = ['firstname', 'lastname', 'dob'];
$changes = [];
foreach ($fields as $field) {
$oldValue = trim((string) ($original[$field] ?? ''));
$newValue = trim((string) ($updated[$field] ?? ''));
if ($oldValue !== $newValue) {
$changes[$field] = [
'old_value' => $oldValue,
'new_value' => $newValue,
'changed' => true,
'changed_by' => $parentId,
'changed_at' => date('Y-m-d H:i:s'),
'source' => $source,
];
}
}
if ($changes === []) {
return;
}
$this->db->table('enrollment_transition_audits')->insert([
'student_id' => $studentId,
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
'source_school_year' => null,
'action' => 'parent_student_field_edit',
'performed_by' => $parentId,
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
'reason' => $source,
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function parentEditAffectsEligibility(array $original, array $updated): bool
{
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
}
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($previousSchoolYear === null) {
return;
}
$evaluation = service('enrollmentTransition')->evaluateForParent(
$parentId,
$studentId,
$previousSchoolYear,
$targetSchoolYear,
'parent'
);
if (($evaluation['can_enroll'] ?? false) === true) {
return;
}
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
session()->setFlashdata('warning', $message);
}
private function schoolYearConfig(string $schoolYear): array
@@ -2548,7 +2688,7 @@ class ParentController extends BaseController
}
// ✅ 8. Pass $isNew to the student save function
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null, null);
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null);
if ($result) {
$studentAdded = true;
@@ -2775,7 +2915,19 @@ $existing = $this->studentModel
// ---------- UPDATE OR INSERT ----------
if ($studentId) {
$existing = $this->studentModel->find((int) $studentId);
if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== (int) $parentId) {
session()->setFlashdata('error', 'Student record was not found for this parent account.');
return false;
}
$this->auditParentStudentFieldChanges($existing, $studentData, (int) $parentId, 'parent_student_edit');
$this->studentModel->update($studentId, $studentData);
if ($this->parentEditAffectsEligibility($existing, $studentData)) {
$this->recheckEligibilityAfterParentEdit((int) $studentId, (int) $parentId, (string) $schoolYear);
}
} else {
$studentData['registration_date'] = utc_now();
$studentData['tuition_paid'] = 0;
@@ -3148,7 +3300,7 @@ $existing = $this->studentModel
$this->request->setGlobal('post', $formData);
// Save/update
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $id, null, null);
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, null, (int) $id);
return redirect()->to('/parent/child_register')->with('success', 'Student updated!');
}