diff --git a/app/Commands/EnrollmentDataAuditCommand.php b/app/Commands/EnrollmentDataAuditCommand.php new file mode 100644 index 0000000..3068ccd --- /dev/null +++ b/app/Commands/EnrollmentDataAuditCommand.php @@ -0,0 +1,155 @@ + 'Optional school year filter for duplicate enrollment detection.', + '--json' => 'Print machine-readable JSON.', + ]; + + private BaseConnection $db; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $schoolYear = trim((string) (CLI::getOption('school-year') ?? '')); + + $report = [ + 'generated_at' => date('Y-m-d H:i:s'), + 'school_year_filter' => $schoolYear !== '' ? $schoolYear : null, + 'withdrawn_normalization' => $this->auditWithdrawnValues(), + 'duplicate_enrollments' => $this->auditDuplicateEnrollments($schoolYear), + ]; + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return; + } + + CLI::write('Enrollment Data Audit', 'green'); + CLI::newLine(); + CLI::write('Withdrawn / misspelling findings: ' . count($report['withdrawn_normalization']), 'yellow'); + foreach ($report['withdrawn_normalization'] as $row) { + CLI::write(sprintf( + ' [%s] %s.%s = %s', + $row['table'], + $row['column'], + $row['id'] ?? $row['student_id'] ?? '?', + $row['value'] + )); + } + CLI::newLine(); + CLI::write('Duplicate enrollment groups: ' . count($report['duplicate_enrollments']), 'yellow'); + foreach ($report['duplicate_enrollments'] as $group) { + CLI::write(sprintf( + ' student=%d year=%s rows=%d ids=[%s]', + $group['student_id'], + $group['school_year'], + $group['row_count'], + implode(',', $group['enrollment_ids']) + )); + } + } + + /** + * @return list> + */ + private function auditWithdrawnValues(): array + { + $findings = []; + $patterns = ['widthrawan', 'widthrwan', 'withdraw']; + + if ($this->db->tableExists('enrollments')) { + $rows = $this->db->table('enrollments') + ->select('id, student_id, school_year, enrollment_status') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $status = strtolower(trim((string) ($row['enrollment_status'] ?? ''))); + foreach ($patterns as $pattern) { + if ($status === $pattern || str_contains($status, $pattern)) { + $findings[] = [ + 'table' => 'enrollments', + 'column' => 'enrollment_status', + 'id' => (int) ($row['id'] ?? 0), + 'student_id' => (int) ($row['student_id'] ?? 0), + 'school_year' => (string) ($row['school_year'] ?? ''), + 'value' => (string) ($row['enrollment_status'] ?? ''), + 'recommended' => 'withdrawn', + ]; + } + } + } + } + + if ($this->db->tableExists('student_decisions')) { + $rows = $this->db->table('student_decisions') + ->select('id, student_id, school_year, decision, deliberation_decision_standard') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + foreach (['decision', 'deliberation_decision_standard'] as $column) { + $raw = (string) ($row[$column] ?? ''); + $normalized = DeliberationDecision::normalize($raw); + if ($raw !== '' && $normalized === DeliberationDecision::WITHDRAWN && strtoupper($raw) !== DeliberationDecision::WITHDRAWN) { + $findings[] = [ + 'table' => 'student_decisions', + 'column' => $column, + 'id' => (int) ($row['id'] ?? 0), + 'student_id' => (int) ($row['student_id'] ?? 0), + 'school_year' => (string) ($row['school_year'] ?? ''), + 'value' => $raw, + 'recommended' => DeliberationDecision::WITHDRAWN, + ]; + } + } + } + } + + return $findings; + } + + /** + * @return list> + */ + private function auditDuplicateEnrollments(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + $builder = $this->db->table('enrollments') + ->select('student_id, school_year, COUNT(*) AS row_count, GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) AS enrollment_ids', false) + ->groupBy('student_id, school_year') + ->having('row_count >', 1); + + if ($schoolYear !== '') { + $builder->where('school_year', $schoolYear); + } + + $groups = []; + foreach ($builder->get()->getResultArray() as $row) { + $ids = array_values(array_filter(array_map('intval', explode(',', (string) ($row['enrollment_ids'] ?? ''))))); + $groups[] = [ + 'student_id' => (int) ($row['student_id'] ?? 0), + 'school_year' => (string) ($row['school_year'] ?? ''), + 'row_count' => (int) ($row['row_count'] ?? 0), + 'enrollment_ids' => $ids, + ]; + } + + return $groups; + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4116f8a..234dbe6 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -120,6 +120,7 @@ $routes->post('administrator/enrollment-admin/exceptions/create', 'View\Enrollme $routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => $enrollmentAdminFilter]); $routes->get('administrator/financial-aid', 'Administrator\FinancialAidController::index', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); +$routes->post('administrator/financial-aid/(:num)/estimate', 'Administrator\FinancialAidController::estimate/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->post('administrator/financial-aid/(:num)/deny', 'Administrator\FinancialAidController::deny/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $withdrawalFinancialFilter = 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal'; @@ -876,6 +877,7 @@ $routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondPare $routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses', ['filter' => 'auth:parent']); $routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler', ['filter' => 'auth:parent']); +$routes->get('/parent/enrollment_eligibility_refresh', 'View\ParentController::enrollmentEligibilityRefresh', ['filter' => 'auth:parent']); $routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess', ['filter' => 'auth:parent']); $routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure', ['filter' => 'auth:parent']); $routes->get('/parent/payment', 'View\ParentController::viewPayments', ['filter' => 'auth:parent']); diff --git a/app/Controllers/Administrator/FinancialAidController.php b/app/Controllers/Administrator/FinancialAidController.php index fc4a926..78f03d3 100644 --- a/app/Controllers/Administrator/FinancialAidController.php +++ b/app/Controllers/Administrator/FinancialAidController.php @@ -3,9 +3,12 @@ namespace App\Controllers\Administrator; use App\Controllers\BaseController; +use App\Models\FinancialAidEstimateModel; use App\Models\FinancialAidRequestModel; +use App\Models\InvoiceModel; use App\Models\StudentModel; use App\Models\UserModel; +use InvalidArgumentException; use Throwable; class FinancialAidController extends BaseController @@ -49,13 +52,68 @@ class FinancialAidController extends BaseController ? (new StudentModel())->whereIn('id', $studentIds)->findAll() : []; + $schoolYear = (string) ($request['school_year'] ?? ''); + $invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear); + $householdSize = max(1, (int) ($request['household_size'] ?? 1)); + $householdIncome = (float) ($request['household_income'] ?? 0); + $requestedAmount = (float) ($request['requested_amount'] ?? 0); + $estimateModel = new FinancialAidEstimateModel(); + $estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSize, $householdIncome); + $finalRebate = $estimateModel->finalRebate($invoiceBalance, $householdSize, $householdIncome, $requestedAmount); + return view('administrator/financial_aid_review', [ 'requestRow' => $request, 'parent' => $parent, 'students' => $students, + 'schoolYear' => $schoolYear, + 'invoiceBalance' => $invoiceBalance, + 'estimatedAid' => $estimatedAid, + 'finalRebate' => $finalRebate, ]); } + public function estimate(int $id) + { + $request = (new FinancialAidRequestModel())->find($id); + if ($request === null) { + return $this->response->setStatusCode(404)->setJSON(['error' => 'Financial aid request was not found.']); + } + + $householdIncomeRaw = trim((string) $this->request->getPost('household_income')); + $householdSizeRaw = (int) $this->request->getPost('household_size'); + $schoolYear = (string) ($request['school_year'] ?? ''); + $invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear); + + if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) { + return $this->response->setStatusCode(422)->setJSON(['error' => 'Enter a valid household income.']); + } + if ($householdSizeRaw < 1) { + return $this->response->setStatusCode(422)->setJSON(['error' => 'Household size must be at least 1.']); + } + + try { + $estimateModel = new FinancialAidEstimateModel(); + $householdIncome = round((float) $householdIncomeRaw, 2); + $estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSizeRaw, $householdIncome); + $finalRebate = $estimateModel->finalRebate( + $invoiceBalance, + $householdSizeRaw, + $householdIncome, + (float) ($request['requested_amount'] ?? 0) + ); + + return $this->response->setJSON([ + 'invoice_balance' => $invoiceBalance, + 'estimated_aid' => $estimatedAid, + 'final_rebate' => $finalRebate, + 'csrf_token' => csrf_token(), + 'csrf_hash' => csrf_hash(), + ]); + } catch (InvalidArgumentException $e) { + return $this->response->setStatusCode(422)->setJSON(['error' => $e->getMessage()]); + } + } + public function approve(int $id) { try { @@ -113,4 +171,23 @@ class FinancialAidController extends BaseController return (float) ($request['requested_amount'] ?? 0); } + + private function invoiceBalanceForParent(int $parentId, string $schoolYear): float + { + if ($parentId <= 0 || $schoolYear === '') { + return 0.0; + } + + $invoice = (new InvoiceModel()) + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->first(); + + if ($invoice === null) { + return 0.0; + } + + return round((float) ($invoice['balance'] ?? 0), 2); + } } diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php index aceeb8a..6c3053a 100644 --- a/app/Controllers/View/EnrollmentAdminController.php +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -233,7 +233,6 @@ class EnrollmentAdminController extends BaseController $ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']); $now = date('Y-m-d H:i:s'); - $expiresAt = date('Y-m-d H:i:s', strtotime('+30 days')); $this->db->transStart(); @@ -260,7 +259,7 @@ class EnrollmentAdminController extends BaseController 'created_by' => $this->userId(), 'approved_by' => $this->userId(), 'starts_at' => $now, - 'expires_at' => $expiresAt, + 'expires_at' => null, 'updated_at' => $now, ]; @@ -314,7 +313,6 @@ class EnrollmentAdminController extends BaseController $schoolYear = trim((string) ($this->request->getPost('school_year') ?? '')); $reasonCode = trim((string) ($this->request->getPost('reason_code') ?? '')); $reasonNote = trim((string) ($this->request->getPost('reason_note') ?? '')); - $expiresAt = trim((string) ($this->request->getPost('expires_at') ?? '')); $postedCodesByStudent = $this->request->getPost('bypassed_rule_codes_by_student') ?? []; $postedCodesByStudent = is_array($postedCodesByStudent) ? $postedCodesByStudent : []; @@ -327,16 +325,6 @@ class EnrollmentAdminController extends BaseController return redirect()->back()->withInput()->with('error', 'Unable to determine the source school year.'); } - if ($expiresAt === '') { - $expiresAt = date('Y-m-d H:i:s', strtotime('+30 days')); - } else { - try { - $expiresAt = (new \DateTimeImmutable($expiresAt))->format('Y-m-d H:i:s'); - } catch (\Throwable) { - return redirect()->back()->withInput()->with('error', 'Expiration date is invalid.'); - } - } - $now = date('Y-m-d H:i:s'); $transitionService = service('enrollmentTransition'); $saved = 0; @@ -360,7 +348,7 @@ class EnrollmentAdminController extends BaseController $failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes))); $ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $failedCodes)) : $failedCodes; $ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== '')); - $nonOverridable = array_values(array_intersect($ruleCodes, ['STUDENT_NOT_LINKED', 'SOURCE_YEAR_NOT_FOUND', 'TARGET_YEAR_NOT_FOUND', 'ALREADY_ENROLLED'])); + $nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED'])); if ($nonOverridable !== []) { $this->db->transRollback(); return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable)); @@ -393,7 +381,7 @@ class EnrollmentAdminController extends BaseController 'created_by' => $this->userId(), 'approved_by' => $this->userId(), 'starts_at' => $now, - 'expires_at' => $expiresAt, + 'expires_at' => null, 'updated_at' => $now, ]; @@ -496,8 +484,13 @@ class EnrollmentAdminController extends BaseController if (is_numeric($assignedTo) && (int) $assignedTo > 0) { $builder->where('ef.assigned_to', (int) $assignedTo); } + $builder->where('ef.flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED'); $rows = $builder->get()->getResultArray(); + $rows = array_values(array_filter( + $rows, + static fn (array $row): bool => (string) ($row['flag_type'] ?? '') !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED' + )); foreach ($rows as &$row) { $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); $row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? '')); @@ -671,7 +664,6 @@ class EnrollmentAdminController extends BaseController $followupTypes = [ 'PENDING_MAKE_UP_EXAM_PROMOTION' => 'temporary_same_grade', 'CLASS_REASSIGNMENT_REQUIRED' => 'manual_class_required', - 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'manual_class_required', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'exit_required', ]; @@ -707,7 +699,6 @@ class EnrollmentAdminController extends BaseController 'AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', - 'CLASS_CAPACITY_EXCEPTION_REQUIRED', 'SIBLING_LAST_NAME_MISMATCH', 'DEFERRED_DELIBERATION', 'WITHDRAWAL_REVIEW_REQUIRED', @@ -802,7 +793,7 @@ class EnrollmentAdminController extends BaseController throw new \RuntimeException('Selected class section was not found.'); } - $originalEnrollment = $this->latestEnrollment($studentId, $schoolYear); + $originalEnrollment = service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear); $payload = [ 'class_section_id' => $sectionId, 'assigned_class_section_id' => $sectionId, @@ -838,6 +829,26 @@ class EnrollmentAdminController extends BaseController } $this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.'); + + $parentId = (int) ($originalEnrollment['parent_id'] ?? 0); + if ($parentId <= 0) { + $student = $this->db->table('students')->select('parent_id')->where('id', $studentId)->limit(1)->get()->getRowArray(); + $parentId = (int) ($student['parent_id'] ?? 0); + } + if ($parentId > 0) { + try { + (new InvoiceController())->generateInvoice( + (string) $parentId, + $schoolYear, + (string) ($originalEnrollment['semester'] ?? getSemester()) + ); + } catch (\Throwable $e) { + log_message('error', 'Invoice refresh after enrollment admin class assignment failed for parent {parent_id}: {error}', [ + 'parent_id' => $parentId, + 'error' => $e->getMessage(), + ]); + } + } } private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void @@ -853,14 +864,7 @@ class EnrollmentAdminController extends BaseController private function latestEnrollment(int $studentId, string $schoolYear): ?array { - return $this->db->table('enrollments') - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->orderBy('updated_at', 'DESC') - ->orderBy('id', 'DESC') - ->limit(1) - ->get() - ->getRowArray() ?: null; + return service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear); } private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void @@ -1126,7 +1130,6 @@ class EnrollmentAdminController extends BaseController 'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED'], 'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED'], 'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'], - 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_REQUIRED'], 'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED'], 'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN'], 'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION'], @@ -1246,7 +1249,9 @@ class EnrollmentAdminController extends BaseController return 0; } - $builder = $this->db->table('enrollment_flags')->where('status', 'open'); + $builder = $this->db->table('enrollment_flags') + ->where('status', 'open') + ->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED'); if ($schoolYear !== '') { $builder->where('school_year', $schoolYear); } @@ -1260,7 +1265,18 @@ class EnrollmentAdminController extends BaseController return []; } - return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type'); + $types = array_column( + $this->db->table('enrollment_flags') + ->select('flag_type') + ->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED') + ->distinct() + ->orderBy('flag_type') + ->get() + ->getResultArray(), + 'flag_type' + ); + + return array_values(array_filter($types, static fn ($type): bool => (string) $type !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED')); } private function schoolYears(): array diff --git a/app/Controllers/View/InventoryController.php b/app/Controllers/View/InventoryController.php index dec6df6..c9a02dd 100644 --- a/app/Controllers/View/InventoryController.php +++ b/app/Controllers/View/InventoryController.php @@ -145,6 +145,10 @@ class InventoryController extends BaseController } if (($item['type'] ?? '') === 'book') { $this->saveBookClassAssignments($id); + $gradeError = $this->syncBookCategoryGradeRange($data['category_id'] ?? null); + if ($gradeError !== null) { + return redirect()->back()->withInput()->with('error', $gradeError); + } } return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.'); @@ -161,6 +165,10 @@ class InventoryController extends BaseController $initialQty = (int) ($itemData['quantity'] ?? 0); if (($itemData['type'] ?? '') === 'book') { $this->saveBookClassAssignments((int) $itemId); + $gradeError = $this->syncBookCategoryGradeRange($itemData['category_id'] ?? null); + if ($gradeError !== null) { + return redirect()->back()->withInput()->with('error', $gradeError); + } if ($initialQty !== 0) { $this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock'); } else { @@ -229,20 +237,12 @@ class InventoryController extends BaseController // Only books have grade range if ($data['type'] === 'book') { - $gmin = $this->request->getPost('grade_min'); - $gmax = $this->request->getPost('grade_max'); - - $gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin); - $gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax); - - if ($gmin !== null && $gmax !== null && $gmin > $gmax) { - return redirect()->back()->withInput()->with('error', 'Grade Min cannot be greater than Grade Max.'); + $normalized = $this->normalizeGradeRangePost(); + if (is_string($normalized)) { + return redirect()->back()->withInput()->with('error', $normalized); } - if ($gmin !== null && $gmin > 13) $gmin = 13; - if ($gmax !== null && $gmax > 13) $gmax = 13; - - $data['grade_min'] = $gmin; - $data['grade_max'] = $gmax; + $data['grade_min'] = $normalized['grade_min']; + $data['grade_max'] = $normalized['grade_max']; } else { $data['grade_min'] = null; $data['grade_max'] = null; @@ -264,7 +264,14 @@ class InventoryController extends BaseController 'A category with this Type & Name already exists. Please choose a different name or edit the existing one.' ); } - $ok = $this->catModel->update((int)$id, $data); + $current = $this->db->table('inventory_categories')->where('id', (int) $id)->get()->getRowArray(); + if (!$current) { + return redirect()->back()->withInput()->with('error', 'Category not found.'); + } + if (!empty($current['school_year'])) { + $data['school_year'] = $current['school_year']; + } + $ok = $this->writeCategoryRow((int) $id, $data); } else { // Creating: block if already exists if ($existing) { @@ -295,7 +302,9 @@ class InventoryController extends BaseController } if (!$ok) { - return redirect()->back()->withInput()->with('error', 'Failed to save category.'); + $errors = $this->catModel->errors(); + $message = $errors !== [] ? implode(', ', $errors) : 'Failed to save category.'; + return redirect()->back()->withInput()->with('error', $message); } return redirect()->back()->with('success', 'Category saved.'); @@ -531,6 +540,85 @@ class InventoryController extends BaseController } } + /** + * Persist grade_min/grade_max onto the selected book category. + * Returns an error message string on failure, or null on success/no-op. + */ + private function syncBookCategoryGradeRange($categoryId): ?string + { + $categoryId = (int) ($categoryId ?? 0); + if ($categoryId <= 0) { + return null; + } + + // Disabled inputs are omitted from POST; do not wipe an existing category range. + $post = $this->request->getPost(); + if (! is_array($post) + || (! array_key_exists('grade_min', $post) && ! array_key_exists('grade_max', $post)) + ) { + return null; + } + + $normalized = $this->normalizeGradeRangePost(); + if (is_string($normalized)) { + return $normalized; + } + + $category = $this->db->table('inventory_categories')->where('id', $categoryId)->get()->getRowArray(); + if (!$category || ($category['type'] ?? '') !== 'book') { + return 'Selected category was not found or is not a book category.'; + } + + $payload = [ + 'grade_min' => $normalized['grade_min'], + 'grade_max' => $normalized['grade_max'], + ]; + if (!empty($category['school_year'])) { + $payload['school_year'] = $category['school_year']; + } + + if (!$this->writeCategoryRow($categoryId, $payload)) { + return 'Failed to update category grade range.'; + } + + return null; + } + + private function writeCategoryRow(int $id, array $data): bool + { + $data['updated_at'] = date('Y-m-d H:i:s'); + + return (bool) $this->db->table('inventory_categories')->where('id', $id)->update($data); + } + + /** + * @return array{grade_min: ?int, grade_max: ?int}|string + */ + private function normalizeGradeRangePost() + { + $gmin = $this->request->getPost('grade_min'); + $gmax = $this->request->getPost('grade_max'); + + $gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int) $gmin); + $gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int) $gmax); + + if ($gmin !== null && $gmin > 13) { + $gmin = 13; + } + if ($gmax !== null && $gmax > 13) { + $gmax = 13; + } + + if ($gmin !== null && $gmax !== null && $gmin > $gmax) { + return 'Grade Min cannot be greater than Grade Max.'; + } + + return [ + 'grade_min' => $gmin, + 'grade_max' => $gmax, + ]; + } + private function moneyValueToCents($value): int { $raw = trim((string) $value); diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index a248587..119a29a 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -105,104 +105,11 @@ class InvoiceController extends ResourceController log_message('info', "Selected school year for invoice retrieval: $schoolYear"); $invoiceData = []; - $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear); + $parents = $this->invoiceManagementParents($schoolYear); foreach ($parents as $parent) { - $students = $this->studentModel->where('parent_id', $parent['id'])->findAll(); - - $parentData = [ - 'parent_name' => $parent['firstname'] . ' ' . $parent['lastname'], - 'parent_id' => $parent['id'], - 'enrolledKids' => [], - 'withdrawnKids' => [], - 'invoice_amount' => 0, - 'refund_amount' => 0, // default - 'last_updated' => null, - 'invoice_date' => null - ]; - - // Fetch most recent invoice - $invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear); - foreach ($invoices as $invoice) { - if ($invoice) { - $parentData['invoice_amount'] = $invoice['total_amount']; - $parentData['last_updated'] = $invoice['updated_at']; - // Prefer issue_date (UTC) and render in configured/user local time for display - $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $parentData['invoice_date'] = !empty($invoice['issue_date']) - ? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC'))) - ->setTimezone(new \DateTimeZone($tzName)) - ->format('Y-m-d H:i:s') - : ($invoice['updated_at'] ?? null); - $parentData['invoice_id'] = $invoice['id']; - - $refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear); - $parentData['refund_amount'] = $refundSummary['amount']; - $parentData['refund_details'] = $refundSummary['details']; - - log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}"); - } else { - log_message('error', "No invoice found for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear."); - } - } - - foreach ($students as $student) { - $studentClass = $this->db->table('student_class') - ->where('student_id', $student['id']) - ->where('school_year', $schoolYear) - ->get()->getRowArray(); - - $grade = 'N/A'; - if ($studentClass && isset($studentClass['class_section_id'])) { - $classSection = $this->db->table('classSection') - ->where('class_section_id', $studentClass['class_section_id']) - ->get()->getRowArray(); - - if ($classSection && isset($classSection['class_section_name'])) { - $grade = $classSection['class_section_name']; - } - } - - $enrollments = $this->enrollmentModel - ->where('student_id', $student['id']) - ->where('school_year', $schoolYear) - ->findAll(); - - foreach ($enrollments as $enrollment) { - $kidData = [ - 'name' => $student['firstname'] . ' ' . $student['lastname'], - 'grade' => $grade, - 'tuition_fee' => $enrollment['tuition_fee'] ?? 0 - ]; - - switch ($enrollment['enrollment_status']) { - case 'payment pending': - case 'enrolled': - $parentData['enrolledKids'][] = $kidData; - break; - case 'withdraw under review': - case 'withdrawn': - case 'refund pending': - $parentData['withdrawnKids'][] = $kidData; - break; - case 'admission under review': - log_message('info', "Student ID {$student['id']} is under admission review and not included in the invoice."); - break; - case 'waitlist': - log_message('info', "Student ID {$student['id']} is in waitlist and not included in the invoice."); - break; - case 'denied': - log_message('info', "Student ID {$student['id']} is denied and not included in the invoice."); - break; - default: - log_message('error', "Unexpected enrollment status '{$enrollment['enrollment_status']}' for student ID {$student['id']}."); - break; - } - } - } - - if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) { - $invoiceData[] = $parentData; + foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) { + $invoiceData[] = $row; } } @@ -370,94 +277,11 @@ class InvoiceController extends ResourceController $schoolYears = [$this->schoolYear]; } - $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear); + $parents = $this->invoiceManagementParents($schoolYear); foreach ($parents as $parent) { - $students = $this->studentModel->where('parent_id', $parent['id'])->findAll(); - - $parentData = [ - 'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')), - 'parent_id' => (int)$parent['id'], - 'enrolledKids' => [], - 'withdrawnKids' => [], - 'invoice_amount'=> 0, - 'refund_amount' => 0, - 'last_updated' => null, - 'invoice_date' => null, - 'invoice_id' => null, - ]; - - // Latest invoice - $invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear); - foreach ($invoices as $invoice) { - if ($invoice) { - $parentData['invoice_amount'] = (float)($invoice['total_amount'] ?? 0); - $parentData['last_updated'] = $invoice['updated_at'] ?? null; - // Prefer issue_date (UTC) -> local; fall back to updated_at/created_at - if (!empty($invoice['issue_date'])) { - $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $parentData['invoice_date'] = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC'))) - ->setTimezone(new \DateTimeZone($tzName)) - ->format('Y-m-d H:i:s'); - } else { - $parentData['invoice_date'] = date('Y-m-d H:i:s', strtotime($invoice['updated_at'] ?? $invoice['created_at'] ?? 'now')); - } - $parentData['invoice_id'] = $invoice['id'] ?? null; - - $refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear); - $parentData['refund_amount'] = $refundSummary['amount']; - $parentData['refund_details'] = $refundSummary['details']; - break; // only most recent as before - } - } - - // Build kids lists based on enrollment statuses - foreach ($students as $student) { - $studentClass = $this->db->table('student_class') - ->where('student_id', $student['id']) - ->where('school_year', $schoolYear) - ->get()->getRowArray(); - - $grade = 'N/A'; - if ($studentClass && isset($studentClass['class_section_id'])) { - $classSection = $this->db->table('classSection') - ->where('class_section_id', $studentClass['class_section_id']) - ->get()->getRowArray(); - if ($classSection && isset($classSection['class_section_name'])) { - $grade = $classSection['class_section_name']; - } - } - - $enrollments = $this->enrollmentModel - ->where('student_id', $student['id']) - ->where('school_year', $schoolYear) - ->findAll(); - - foreach ($enrollments as $enrollment) { - $kid = [ - 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'grade' => $grade, - 'tuition_fee' => (float)($enrollment['tuition_fee'] ?? 0), - ]; - switch ($enrollment['enrollment_status']) { - case 'payment pending': - case 'enrolled': - $parentData['enrolledKids'][] = $kid; - break; - case 'withdraw under review': - case 'withdrawn': - case 'refund pending': - $parentData['withdrawnKids'][] = $kid; - break; - default: - // ignore others for invoice summary - break; - } - } - } - - if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) { - $invoiceData[] = $parentData; + foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) { + $invoiceData[] = $row; } } @@ -610,7 +434,9 @@ class InvoiceController extends ResourceController $updated = false; $updatedIds = []; if (!empty($invoice) && isset($invoice['id'])) { - $ledger = $this->invoiceLedgerService->recalculate((int) $invoice['id']); + $invoiceId = (int) $invoice['id']; + $this->invoiceLedgerService->syncTuitionLines($invoiceId); + $ledger = $this->invoiceLedgerService->recalculate($invoiceId); $updatedIds[] = (int) $ledger['invoice_id']; log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); $updated = true; @@ -767,39 +593,273 @@ class InvoiceController extends ResourceController return null; } - // Prefer invoices flagged as having discounts. - $invoice = $this->invoiceModel + $invoices = $this->invoiceModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) - ->where('has_discount', 1) ->orderBy('id', 'DESC') - ->first(); - if (!empty($invoice)) { - return $invoice; + ->findAll(); + + $tuitionInvoices = []; + foreach ($invoices as $invoice) { + if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) { + continue; + } + $tuitionInvoices[] = $invoice; + } + + if ($tuitionInvoices === []) { + return null; + } + + foreach ($tuitionInvoices as $invoice) { + if ((int) ($invoice['has_discount'] ?? 0) === 1) { + return $invoice; + } } - // Fallback: prefer invoices with discount_usages rows. try { - $row = $this->db->table('invoices i') - ->select('i.*') - ->join('discount_usages du', 'du.invoice_id = i.id', 'inner') - ->where('i.parent_id', $parentId) - ->where('i.school_year', $schoolYear) - ->orderBy('i.id', 'DESC') - ->get() - ->getRowArray(); - if (!empty($row)) { - return $row; + $tuitionInvoiceIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $tuitionInvoices + ))); + if ($tuitionInvoiceIds !== []) { + $row = $this->db->table('invoices i') + ->select('i.*') + ->join('discount_usages du', 'du.invoice_id = i.id', 'inner') + ->whereIn('i.id', $tuitionInvoiceIds) + ->orderBy('i.id', 'DESC') + ->get() + ->getRowArray(); + if (! empty($row)) { + return $row; + } } } catch (\Throwable $e) { } - // Final fallback: latest invoice for parent/year. - return $this->invoiceModel - ->where('parent_id', $parentId) + return $tuitionInvoices[0]; + } + + /** + * @return list> + */ + private function invoiceManagementParents(string $schoolYear): array + { + $byId = []; + foreach ($this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear) as $parent) { + $byId[(int) ($parent['id'] ?? 0)] = $parent; + } + + $invoiceParentRows = $this->invoiceModel + ->select('parent_id') + ->distinct() ->where('school_year', $schoolYear) - ->orderBy('id', 'DESC') - ->first(); + ->findAll(); + + foreach ($invoiceParentRows as $row) { + $parentId = (int) ($row['parent_id'] ?? 0); + if ($parentId <= 0 || isset($byId[$parentId])) { + continue; + } + + $parent = $this->userModel->find($parentId); + if ($parent !== null) { + $byId[$parentId] = $parent; + } + } + + return array_values($byId); + } + + /** + * @return list> + */ + private function invoiceManagementRowsForParent(array $parent, string $schoolYear): array + { + $parentId = (int) ($parent['id'] ?? 0); + if ($parentId <= 0 || $schoolYear === '') { + return []; + } + + $kids = $this->invoiceManagementKidsForParent($parentId, $schoolYear); + $enrolledKids = $kids['enrolledKids']; + $withdrawnKids = $kids['withdrawnKids']; + $rows = []; + + foreach ($this->carryForwardInvoicesForParentYear($parentId, $schoolYear) as $carryForwardInvoice) { + $rows[] = $this->buildInvoiceManagementRow( + $parent, + $carryForwardInvoice, + true, + [], + [], + $this->paidRefundSummaryForInvoice((int) ($carryForwardInvoice['id'] ?? 0)) + ); + } + + $tuitionInvoice = $this->selectActiveInvoiceForParentYear($parentId, $schoolYear); + if ($enrolledKids !== [] || $withdrawnKids !== [] || $tuitionInvoice !== null) { + $rows[] = $this->buildInvoiceManagementRow( + $parent, + $tuitionInvoice, + false, + $enrolledKids, + $withdrawnKids, + $this->paidRefundSummaryForParentYear($parentId, $schoolYear) + ); + } + + return $rows; + } + + /** + * @return array{enrolledKids: list>, withdrawnKids: list>} + */ + private function invoiceManagementKidsForParent(int $parentId, string $schoolYear): array + { + $enrolledKids = []; + $withdrawnKids = []; + $students = $this->studentModel->where('parent_id', $parentId)->findAll(); + + foreach ($students as $student) { + $studentClass = $this->db->table('student_class') + ->where('student_id', $student['id']) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + $grade = 'N/A'; + if ($studentClass && isset($studentClass['class_section_id'])) { + $classSection = $this->db->table('classSection') + ->where('class_section_id', $studentClass['class_section_id']) + ->get() + ->getRowArray(); + if ($classSection && isset($classSection['class_section_name'])) { + $grade = $classSection['class_section_name']; + } + } + + $enrollments = $this->enrollmentModel + ->where('student_id', $student['id']) + ->where('school_year', $schoolYear) + ->findAll(); + + foreach ($enrollments as $enrollment) { + $kid = [ + 'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')), + 'grade' => $grade, + 'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0), + ]; + + switch ($enrollment['enrollment_status']) { + case 'payment pending': + case 'enrolled': + $enrolledKids[] = $kid; + break; + case 'withdraw under review': + case 'withdrawn': + case 'refund pending': + $withdrawnKids[] = $kid; + break; + } + } + } + + return [ + 'enrolledKids' => $enrolledKids, + 'withdrawnKids' => $withdrawnKids, + ]; + } + + /** + * @return list> + */ + private function carryForwardInvoicesForParentYear(int $parentId, string $schoolYear): array + { + if ($parentId <= 0 || $schoolYear === '') { + return []; + } + + return array_values(array_filter( + $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'ASC') + ->findAll(), + fn (array $invoice): bool => $this->invoiceLedgerService->invoiceIsCarryForward($invoice) + )); + } + + /** + * @param list> $enrolledKids + * @param list> $withdrawnKids + * @param array{amount: float, details: list>} $refundSummary + * @return array + */ + private function buildInvoiceManagementRow( + array $parent, + ?array $invoice, + bool $isCarryForward, + array $enrolledKids, + array $withdrawnKids, + array $refundSummary + ): array { + $parentId = (int) ($parent['id'] ?? 0); + $invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null; + $invoiceDate = null; + + if ($invoice !== null) { + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $invoiceDate = ! empty($invoice['issue_date']) + ? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC'))) + ->setTimezone(new \DateTimeZone($tzName)) + ->format('Y-m-d H:i:s') + : ($invoice['updated_at'] ?? null); + } + + $description = ''; + if ($isCarryForward && $invoice !== null) { + $description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice); + } + + return [ + 'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')), + 'parent_id' => $parentId, + 'enrolledKids' => $enrolledKids, + 'withdrawnKids' => $withdrawnKids, + 'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0, + 'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0, + 'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0), + 'refund_details' => $refundSummary['details'] ?? [], + 'last_updated' => $invoice['updated_at'] ?? null, + 'invoice_date' => $invoiceDate, + 'invoice_id' => $invoiceId, + 'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '', + 'invoice_description' => $description, + 'invoice_status' => $invoice !== null ? (string) ($invoice['status'] ?? '') : '', + 'is_carry_forward' => $isCarryForward, + ]; + } + + /** + * @return array{amount: float, details: list>} + */ + private function paidRefundSummaryForInvoice(int $invoiceId): array + { + if ($invoiceId <= 0) { + return ['amount' => 0.0, 'details' => []]; + } + + $details = $this->paidRefundDetailsForInvoice($invoiceId); + $amount = 0.0; + foreach ($details as $detail) { + $amount += (float) ($detail['amount'] ?? 0.0); + } + + return [ + 'amount' => round($amount, 2), + 'details' => $details, + ]; } private function recalculateAndUpdateDiscount( @@ -854,11 +914,15 @@ class InvoiceController extends ResourceController private function calculateTotalTuitionFee(array $students): float { + $schoolYear = (string) ($students[0]['school_year'] ?? $this->schoolYear); + // 1) Normalize each student's grade name (once) foreach ($students as &$student) { - $gradeName = $this->classSectionModel - ->getClassSectionNameBySectionId($student['class_section_id']); - $student['grade'] = strtoupper(trim($gradeName)); + $student['grade'] = $this->resolveStudentGradeName( + (int) ($student['student_id'] ?? 0), + $schoolYear, + $student['class_section_id'] ?? null + ); } unset($student); // break reference @@ -876,6 +940,29 @@ class InvoiceController extends ResourceController return $total; } + private function resolveStudentGradeName(int $studentId, string $schoolYear, $classSectionId = null): string + { + $sectionId = $classSectionId; + if (empty($sectionId) && $studentId > 0 && $schoolYear !== '') { + $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'; + } + + $gradeName = $this->classSectionModel->getClassSectionNameBySectionId($sectionId); + + return strtoupper(trim((string) $gradeName)); + } + // Method to check and generate an invoice when enrollment status changes public function checkAndGenerateInvoice($parentId, $status) @@ -922,6 +1009,10 @@ class InvoiceController extends ResourceController return ['error' => "No invoice was generated. Please contact the school administration."]; } + if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) { + return $this->prepareCarryForwardInvoiceData($invoice); + } + $parentId = $invoice['parent_id']; $schoolYear = $invoice['school_year']; @@ -1141,6 +1232,8 @@ class InvoiceController extends ResourceController return [ 'invoice' => $invoice, 'parent' => $parent, + 'isCarryForwardInvoice' => $this->invoiceLedgerService->invoiceIsCarryForward($invoice), + 'carryForwardDescription'=> $this->invoiceLedgerService->carryForwardDisplayDescription($invoice), 'registeredKids' => $registeredKids, 'withdrawnKids' => $withdrawnKids, 'studentCharges' => $studentCharges, @@ -1157,6 +1250,85 @@ class InvoiceController extends ResourceController ]; } + private function prepareCarryForwardInvoiceData(array $invoice): array + { + $invoiceId = (int) ($invoice['id'] ?? 0); + $parentId = (int) ($invoice['parent_id'] ?? 0); + $schoolYear = (string) ($invoice['school_year'] ?? ''); + + $parent = $this->userModel->find($parentId); + if (! $parent) { + return ['error' => 'Parent associated with the invoice was not found.']; + } + + $ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId); + $carryForwardDescription = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice); + $invoice['description'] = $carryForwardDescription; + + $carryForwardAmount = (float) ($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0); + if (abs($carryForwardAmount) >= 0.01) { + try { + $this->invoiceLedgerService->issueCarryForwardInvoiceLine( + $invoiceId, + $carryForwardAmount, + $carryForwardDescription + ); + $ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId); + } catch (\Throwable $e) { + log_message('warning', 'Unable to normalize carry-forward invoice line for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + } + + $db = $this->db; + $table = $this->paymentModel->table; + $hasStatus = $db->fieldExists('status', $table); + $hasVoid = $db->fieldExists('is_void', $table); + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + + $paymentQuery = $this->paymentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('invoice_id', $invoiceId); + + if ($hasStatus) { + $paymentQuery->groupStart() + ->whereNotIn('status', $exclude) + ->orWhere('status IS NULL', null, false) + ->groupEnd(); + } + + if ($hasVoid) { + $paymentQuery->groupStart() + ->where('is_void', 0) + ->orWhere('is_void IS NULL', null, false) + ->groupEnd(); + } + + $payments = $paymentQuery->findAll(); + $refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0); + $refundDetails = $this->paidRefundDetailsForInvoice($invoiceId); + + return [ + 'invoice' => $invoice, + 'parent' => $parent, + 'isCarryForwardInvoice' => true, + 'carryForwardDescription' => $carryForwardDescription, + 'registeredKids' => [], + 'withdrawnKids' => [], + 'studentCharges' => [], + 'events' => [], + 'students' => [], + 'payments' => $payments, + 'discounts' => [], + 'additionalChargesTotal' => 0.0, + 'additionalChargeLines' => [], + 'invoiceLines' => [], + 'refundsPaidTotal' => $refundsPaidTotal, + 'refundDetails' => $refundDetails, + 'ledger' => $ledger, + ]; + } + /** * Treat common KG spellings as Kindergarten. @@ -1339,25 +1511,30 @@ class InvoiceController extends ResourceController } $studentTuitionRows = []; - foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) { - $sid = (int)($student['student_id'] ?? 0); - $charge = $studentCharges[$sid] ?? null; - $amount = (float)($charge['unit_fee'] ?? 0.0); - if ($sid <= 0 || abs($amount) < 0.00001) { - continue; - } + $isCarryForwardInvoice = (bool) ($data['isCarryForwardInvoice'] ?? $this->invoiceLedgerService->invoiceIsCarryForward($invoice)); + $carryForwardDescription = (string) ($data['carryForwardDescription'] ?? $this->invoiceLedgerService->carryForwardDisplayDescription($invoice)); - $name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); - $grade = trim((string)($student['grade'] ?? '')); - $desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid); - if ($grade !== '' && strtoupper($grade) !== 'N/A') { - $desc .= ' (' . $grade . ')'; - } + if (! $isCarryForwardInvoice) { + foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) { + $sid = (int)($student['student_id'] ?? 0); + $charge = $studentCharges[$sid] ?? null; + $amount = (float)($charge['unit_fee'] ?? 0.0); + if ($sid <= 0 || abs($amount) < 0.00001) { + continue; + } - $studentTuitionRows[] = [ - 'description' => $desc, - 'amount' => $amount, - ]; + $name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); + $grade = trim((string)($student['grade'] ?? '')); + $desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid); + if ($grade !== '' && strtoupper($grade) !== 'N/A') { + $desc .= ' (' . $grade . ')'; + } + + $studentTuitionRows[] = [ + 'description' => $desc, + 'amount' => $amount, + ]; + } } $eventRows = []; @@ -1390,9 +1567,24 @@ class InvoiceController extends ResourceController $dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true); $amount = ((int)($line['line_amount_cents'] ?? 0)) / 100; $type = (string)($line['line_type'] ?? 'other'); + $sourceType = (string)($line['source_type'] ?? ''); $category = str_contains($type, 'event') ? 'event' : (str_contains($type, 'additional') ? 'additional' : 'registration'); + if ($sourceType === 'carry_forward_invoice' || $sourceType === 'carry_forward_opening_balance') { + $lineDescription = trim((string)($line['description'] ?? '')); + if ($lineDescription === '') { + $lineDescription = $carryForwardDescription; + } + $push($dt, $lineDescription, $amount, 'additional'); + continue; + } + + if ($isCarryForwardInvoice) { + $push($dt, $carryForwardDescription, $amount, 'additional'); + continue; + } + if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) { $expandedTotal = 0.0; foreach ($studentTuitionRows as $row) { @@ -1426,11 +1618,18 @@ class InvoiceController extends ResourceController if (empty($data['invoiceLines'] ?? [])) { $fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true); - foreach ($studentTuitionRows as $row) { - $push($fallbackDt, $row['description'], (float)$row['amount'], 'registration'); - } - foreach ($eventRows as $row) { - $push($fallbackDt, $row['description'], (float)$row['amount'], 'event'); + if ($isCarryForwardInvoice) { + $carryForwardAmount = (float)($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0); + if (abs($carryForwardAmount) >= 0.01) { + $push($fallbackDt, $carryForwardDescription, $carryForwardAmount, 'additional'); + } + } else { + foreach ($studentTuitionRows as $row) { + $push($fallbackDt, $row['description'], (float)$row['amount'], 'registration'); + } + foreach ($eventRows as $row) { + $push($fallbackDt, $row['description'], (float)$row['amount'], 'event'); + } } } @@ -1737,13 +1936,22 @@ private function getGradeLevel($grade): array // Attach refund amount and last payment data to each invoice foreach ($invoices as &$invoice) { + if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) { + $invoice['description'] = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice); + } + $invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.00; $invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.00; $invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null; } + unset($invoice); $invoiceEventCharges = []; foreach ($invoices as $invoice) { + if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) { + continue; + } + $year = $invoice['school_year'] ?? $this->schoolYear; $sem = $invoice['semester'] ?? $this->semester; $invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo( diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index e4bc778..815b5bf 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -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 $fields * @return array{errors: list, data: array} */ - 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 2–50 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 2–30 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 @@ -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!'); } diff --git a/app/Controllers/View/ParentFinancialAidController.php b/app/Controllers/View/ParentFinancialAidController.php index e3bb09b..adc0787 100644 --- a/app/Controllers/View/ParentFinancialAidController.php +++ b/app/Controllers/View/ParentFinancialAidController.php @@ -24,15 +24,8 @@ class ParentFinancialAidController extends BaseController ->orderBy('id', 'DESC') ->findAll(); - $students = (new StudentModel()) - ->where('parent_id', $parentId) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - return view('parent/financial_aid', [ 'schoolYear' => $schoolYear, - 'students' => $students, 'requests' => $requests, 'openRequest' => $model->openRequestForParent($parentId, $schoolYear), 'existingRequest' => $model->requestForParentYear($parentId, $schoolYear), @@ -52,14 +45,27 @@ class ParentFinancialAidController extends BaseController return redirect()->back()->with('error', 'You can submit only one financial aid application per school year.'); } - $studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids'))))); - $linkedIds = array_map('intval', array_column( + $studentIds = array_map('intval', array_column( (new StudentModel())->select('id')->where('parent_id', $parentId)->findAll(), 'id' )); - $studentIds = array_values(array_intersect($studentIds, $linkedIds)); if ($studentIds === []) { - return redirect()->back()->withInput()->with('error', 'Select at least one of your students.'); + return redirect()->back()->withInput()->with('error', 'No students are linked to your account.'); + } + + $householdIncomeRaw = trim((string) $this->request->getPost('household_income')); + if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) { + return redirect()->back()->withInput()->with('error', 'Enter your household income.'); + } + + $householdSize = (int) $this->request->getPost('household_size'); + if ($householdSize < 1) { + return redirect()->back()->withInput()->with('error', 'Enter your household size.'); + } + + $requestedAmountRaw = trim((string) $this->request->getPost('requested_amount')); + if ($requestedAmountRaw === '' || ! is_numeric($requestedAmountRaw) || (float) $requestedAmountRaw <= 0) { + return redirect()->back()->withInput()->with('error', 'Enter the amount you are requesting.'); } $needStatement = trim((string) $this->request->getPost('need_statement')); @@ -67,16 +73,14 @@ class ParentFinancialAidController extends BaseController return redirect()->back()->withInput()->with('error', 'Please describe why you are requesting financial aid.'); } - $householdSize = (int) $this->request->getPost('household_size'); - $requestedAmount = trim((string) $this->request->getPost('requested_amount')); - $model->insert([ 'parent_id' => $parentId, 'school_year' => $schoolYear, - 'student_ids_json' => json_encode($studentIds), - 'household_size' => $householdSize > 0 ? $householdSize : null, + 'student_ids_json' => json_encode(array_values($studentIds)), + 'household_size' => $householdSize, + 'household_income' => round((float) $householdIncomeRaw, 2), 'need_statement' => $needStatement, - 'requested_amount' => $requestedAmount !== '' ? (float) $requestedAmount : null, + 'requested_amount' => round((float) $requestedAmountRaw, 2), 'status' => 'submitted', ]); diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 30405f1..396ef45 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -525,16 +525,15 @@ class PaymentController extends ResourceController return 0; } - // 2) Payment check: recompute current balance from payments (authoritative) - $total = (float) ($invoice['total_amount'] ?? 0); - $currentBal = $this->getCurrentInvoiceBalance($invoiceId); // <-- uses the safe helper - if (!($total > 0 && $currentBal < $total)) { - log_message('info', 'No payment yet (or still full balance). Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); + // 2) Payment check: enrollment transitions only after the invoice is fully paid + $currentBal = $this->getCurrentInvoiceBalance($invoiceId); + if ($currentBal > 0.00001) { + log_message('info', 'Invoice not fully paid. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); return 0; } $parentId = (int) ($invoice['parent_id'] ?? 0); - $schoolYear = (string) $this->schoolYear; + $schoolYear = (string) ($invoice['school_year'] ?? $this->schoolYear); $semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null; if ($parentId <= 0 || $schoolYear === '') { diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index 94d5c91..5f66bf3 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -214,22 +214,41 @@ class StudentController extends BaseController $attnStats = []; $scoreStats = []; + $invoiceParentId = 0; if (!$isEventOnly) { // Update enrollment for current term (if exists) - $enroll = $this->enrollmentModel - ->where('student_id', $studentId) - ->where('school_year', (string)$this->schoolYear) - ->where('semester', (string)$this->semester) - ->first(); + $enroll = \Config\Services::enrollmentStatus(false) + ->controllingEnrollment($studentId, (string) $this->schoolYear); if ($enroll) { + $invoiceParentId = (int) ($enroll['parent_id'] ?? $student['parent_id'] ?? 0); + $parentId = $invoiceParentId; + if ($parentId > 0) { + $advance = service('enrollmentTransition')->evaluateEnrollmentAdvance( + $parentId, + $studentId, + (string) $this->schoolYear, + 'admin' + ); + if (! service('enrollmentTransition')->adminMayAdvanceEnrollment($advance)) { + service('enrollmentTransition')->logEnrollmentBlock( + $advance, + 'assign_class_student', + $parentId, + $userId > 0 ? $userId : null + ); + $msg = (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.'); + throw new \RuntimeException($msg); + } + } + $enPk = $this->enrollmentModel->primaryKey ?? 'id'; $result = \Config\Services::enrollmentStatus(false)->upsertStatus([ 'id' => (int) $enroll[$enPk], 'student_id' => $studentId, 'parent_id' => (int) ($enroll['parent_id'] ?? 0), 'school_year' => (string) $this->schoolYear, - 'semester' => (string) $this->semester, + 'semester' => (string) ($enroll['semester'] ?? $this->semester), 'class_section_id' => $primarySectionId, 'enrollment_status' => 'payment pending', // Ensure admission is marked accepted once moved out of review @@ -267,6 +286,21 @@ class StudentController extends BaseController $this->db->transCommit(); + if (! $isEventOnly && $invoiceParentId > 0) { + try { + (new InvoiceController())->generateInvoice( + (string) $invoiceParentId, + (string) $this->schoolYear, + (string) $this->semester + ); + } catch (\Throwable $e) { + log_message('error', 'Invoice refresh after class assignment failed for parent {parent_id}: {error}', [ + 'parent_id' => $invoiceParentId, + 'error' => $e->getMessage(), + ]); + } + } + $resp = [ 'ok' => true, 'student_id' => $studentId, @@ -680,7 +714,7 @@ class StudentController extends BaseController $cands = $this->distributionCandidates($classId, $year); if (empty($cands)) { - $msg = 'No promoted students found to distribute for selected class/year.'; + $msg = 'No students found to distribute for the selected class/year.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg); } @@ -1592,6 +1626,9 @@ class StudentController extends BaseController $scoreField = $this->studentDecisionScoreField(); $select = 'sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.decision, students.firstname, students.lastname, students.gender, students.age, students.dob'; + if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $select .= ', sd.deliberation_decision_standard'; + } if ($scoreField !== null) { $select .= ', sd.' . $scoreField . ' AS year_score'; } @@ -1616,13 +1653,15 @@ class StudentController extends BaseController continue; } $seen[$studentId] = true; - if (DeliberationDecision::normalize($row['decision'] ?? null) !== DeliberationDecision::PASSED) { + $normalizedDecision = $this->normalizedDecisionFromRow($row); + if ($normalizedDecision !== DeliberationDecision::PASSED + && $normalizedDecision !== DeliberationDecision::REPEAT_CLASS) { continue; } $targetClassId = $this->targetClassIdFromDecision( (string)($row['class_section_name'] ?? ''), - (string)($row['decision'] ?? ''), + $normalizedDecision ?? (string)($row['decision'] ?? ''), $targetSchoolYear ); $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear); @@ -1760,8 +1799,13 @@ class StudentController extends BaseController return $this->distributionExcludedDecisionCache[$previousSchoolYear]; } + $select = 'student_id, decision'; + if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $select .= ', deliberation_decision_standard'; + } + $rows = $this->db->table('student_decisions') - ->select('student_id, decision') + ->select($select) ->where('school_year', $previousSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') @@ -1777,7 +1821,8 @@ class StudentController extends BaseController } $seen[$studentId] = true; - if ($this->isDistributionExcludedDecision((string)($row['decision'] ?? ''))) { + $normalizedDecision = $this->normalizedDecisionFromRow($row); + if ($normalizedDecision !== null && $this->isDistributionExcludedDecision($normalizedDecision)) { $excluded[$studentId] = true; } } @@ -1787,9 +1832,25 @@ class StudentController extends BaseController return $excluded; } + private function normalizedDecisionFromRow(array $row): ?string + { + return DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($row['decision'] ?? null); + } + private function isDistributionExcludedDecision(string $decision): bool { - return DeliberationDecision::normalize($decision) !== DeliberationDecision::PASSED; + $normalized = DeliberationDecision::normalize($decision); + if ($normalized === null) { + return true; + } + + return in_array($normalized, [ + DeliberationDecision::EXPELLED, + DeliberationDecision::WITHDRAWN, + DeliberationDecision::DEFERRED_DECISION, + DeliberationDecision::MAKE_UP_EXAM, + ], true); } private function distributionPreviousClassSectionName(int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear = null): string diff --git a/app/Database/Migrations/2026-08-24-000100_AddHouseholdIncomeToFinancialAidRequests.php b/app/Database/Migrations/2026-08-24-000100_AddHouseholdIncomeToFinancialAidRequests.php new file mode 100644 index 0000000..2168378 --- /dev/null +++ b/app/Database/Migrations/2026-08-24-000100_AddHouseholdIncomeToFinancialAidRequests.php @@ -0,0 +1,35 @@ +db->tableExists('financial_aid_requests')) { + return; + } + + if (! in_array('household_income', $this->db->getFieldNames('financial_aid_requests'), true)) { + $this->forge->addColumn('financial_aid_requests', [ + 'household_income' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'null' => true, + 'after' => 'household_size', + ], + ]); + } + } + + public function down() + { + if ($this->db->tableExists('financial_aid_requests') + && in_array('household_income', $this->db->getFieldNames('financial_aid_requests'), true) + ) { + $this->forge->dropColumn('financial_aid_requests', 'household_income'); + } + } +} diff --git a/app/Database/Migrations/2026-08-24-120000_RemoveRegistrationAndMandatoryFees.php b/app/Database/Migrations/2026-08-24-120000_RemoveRegistrationAndMandatoryFees.php new file mode 100644 index 0000000..1aa95d9 --- /dev/null +++ b/app/Database/Migrations/2026-08-24-120000_RemoveRegistrationAndMandatoryFees.php @@ -0,0 +1,50 @@ +db->tableExists('school_years')) { + return; + } + + foreach (['registration_fee', 'mandatory_fees'] as $column) { + if ($this->db->fieldExists($column, 'school_years')) { + $this->forge->dropColumn('school_years', $column); + } + } + } + + public function down() + { + if (! $this->db->tableExists('school_years')) { + return; + } + + if (! $this->db->fieldExists('registration_fee', 'school_years')) { + $this->forge->addColumn('school_years', [ + 'registration_fee' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0, + 'after' => 'adult_student_registration_enabled', + ], + ]); + } + + if (! $this->db->fieldExists('mandatory_fees', 'school_years')) { + $this->forge->addColumn('school_years', [ + 'mandatory_fees' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0, + 'after' => 'tuition_due_at_registration', + ], + ]); + } + } +} diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index e11ffce..727c617 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -173,6 +173,76 @@ class InvoiceLedgerService return $this->recalculateInvoice($invoiceId); } + /** + * Refresh frozen tuition invoice lines to match the current enrollment/class assignment state. + * Event and additional charge lines are left unchanged. + */ + public function syncTuitionLines(int $invoiceId): bool + { + $invoice = $this->loadInvoice($invoiceId); + if ($invoice === null || $this->isCarryForwardInvoice($invoice) || ! $this->invoiceLinesAvailable()) { + return false; + } + + if (! $this->invoiceHasLines($invoiceId)) { + return false; + } + + $currentTuitionCents = $this->toCents($this->calculateTuitionTotal($invoice)); + $frozen = $this->calculateFrozenLineTotals($invoiceId); + $frozenTuitionCents = (int) ($frozen['tuition_cents'] ?? 0); + + if ($currentTuitionCents === $frozenTuitionCents) { + return false; + } + + $now = utc_now(); + $this->invoiceLineModel() + ->where('invoice_id', $invoiceId) + ->where('voided_at IS NULL', null, false) + ->groupStart() + ->where('line_type', 'tuition') + ->orWhere('source_type', 'tuition_calculation') + ->groupEnd() + ->set([ + 'voided_at' => $now, + 'updated_at' => $now, + ]) + ->update(); + + if ($currentTuitionCents === 0) { + return true; + } + + $metadata = [ + 'parent_id' => (int) ($invoice['parent_id'] ?? 0), + 'school_year' => (string) ($invoice['school_year'] ?? ''), + 'semester' => (string) ($invoice['semester'] ?? ''), + 'synced_at' => $now, + ]; + + $lineId = $this->invoiceLineModel()->insert( + $this->buildInvoiceLineRow( + $invoiceId, + 'tuition', + 'tuition_calculation', + null, + 'Tuition charges', + $currentTuitionCents, + 1, + $this->getCalculationVersion(), + $metadata, + $now + ) + ); + + if (! $lineId) { + throw new FinancialPersistenceException('INVOICE_TUITION_SYNC_FAILED', $this->invoiceLineModel()->errors()); + } + + return true; + } + public function getValidPaymentTotalCents(int $invoiceId): int { return $this->toCents($this->calculateValidPayments($invoiceId)); @@ -428,9 +498,129 @@ class InvoiceLedgerService return str_contains($description, 'carried over') || str_contains($description, 'carry-forward') + || str_contains($description, 'carry over') || str_contains($description, 'previous school year'); } + public function invoiceIsCarryForward(array $invoice): bool + { + return $this->isCarryForwardInvoice($invoice); + } + + public function carryForwardDisplayDescription(array $invoice): string + { + $sourceYear = $this->carryForwardSourceSchoolYear($invoice); + if ($sourceYear !== '') { + return 'Carry over balance from last year ' . $sourceYear; + } + + return 'Carry over balance from last year'; + } + + /** + * Issue a single carry-over line on an opening-balance invoice. No student names are used. + */ + public function issueCarryForwardInvoiceLine(int $invoiceId, float $amount, string $description): int + { + if ($invoiceId <= 0 || ! $this->invoiceLinesAvailable()) { + return 0; + } + + $invoice = $this->loadInvoice($invoiceId); + if ($invoice === null || ! $this->isCarryForwardInvoice($invoice)) { + return 0; + } + + $amountCents = $this->toCents($amount); + if ($amountCents === 0) { + return 0; + } + + $description = trim($description); + if ($description === '') { + $description = $this->carryForwardDisplayDescription($invoice); + } + + $existingLine = $this->invoiceLineModel() + ->where('invoice_id', $invoiceId) + ->where('source_type', 'carry_forward_opening_balance') + ->where('voided_at IS NULL', null, false) + ->first(); + + $now = utc_now(); + + $this->invoiceLineModel() + ->where('invoice_id', $invoiceId) + ->where('voided_at IS NULL', null, false) + ->groupStart() + ->where('line_type', 'tuition') + ->orWhere('source_type', 'tuition_calculation') + ->groupEnd() + ->set([ + 'voided_at' => $now, + 'updated_at' => $now, + ]) + ->update(); + + if ($existingLine !== null) { + $this->invoiceLineModel()->update((int) $existingLine['id'], [ + 'description' => $description, + 'unit_amount_cents' => $amountCents, + 'line_amount_cents' => $amountCents, + 'updated_at' => $now, + ]); + + return (int) $existingLine['id']; + } + + $lineId = $this->invoiceLineModel()->insert( + $this->buildInvoiceLineRow( + $invoiceId, + 'additional_charge', + 'carry_forward_opening_balance', + $invoiceId, + $description, + $amountCents, + 0, + $this->getCalculationVersion(), + ['carry_forward' => true], + $now + ) + ); + + if (! $lineId) { + throw new FinancialPersistenceException('INVOICE_CARRY_FORWARD_LINE_FAILED', $this->invoiceLineModel()->errors()); + } + + return (int) $lineId; + } + + private function carryForwardSourceSchoolYear(array $invoice): string + { + $description = (string) ($invoice['description'] ?? ''); + if (preg_match('/last year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) { + return $matches[1]; + } + if (preg_match('/previous school year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) { + return $matches[1]; + } + + $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); + if (preg_match('/^CF-(\d{8})-/i', $invoiceNumber, $matches) === 1) { + $compact = $matches[1]; + if (strlen($compact) === 8) { + return substr($compact, 0, 4) . '-' . substr($compact, 4, 4); + } + } + + $targetYear = trim((string) ($invoice['school_year'] ?? '')); + if (preg_match('/^(\d{4})-(\d{4})$/', $targetYear, $matches) === 1) { + return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); + } + + return ''; + } + protected function calculateTuitionTotal(array $invoice): float { $parentId = (int) ($invoice['parent_id'] ?? 0); diff --git a/app/Models/FinancialAidEstimateModel.php b/app/Models/FinancialAidEstimateModel.php new file mode 100644 index 0000000..5e4b72c --- /dev/null +++ b/app/Models/FinancialAidEstimateModel.php @@ -0,0 +1,51 @@ +estimatedAid($totalBalance, $householdSize, $householdIncome); + + return min($estimated, $amountRequested, $totalBalance); + } +} diff --git a/app/Models/FinancialAidRequestModel.php b/app/Models/FinancialAidRequestModel.php index 9557e6b..83bcf2c 100644 --- a/app/Models/FinancialAidRequestModel.php +++ b/app/Models/FinancialAidRequestModel.php @@ -16,6 +16,7 @@ class FinancialAidRequestModel extends Model 'school_year', 'student_ids_json', 'household_size', + 'household_income', 'need_statement', 'requested_amount', 'status', diff --git a/app/Models/InventoryCategoryModel.php b/app/Models/InventoryCategoryModel.php index b5f3cc9..e298d0d 100644 --- a/app/Models/InventoryCategoryModel.php +++ b/app/Models/InventoryCategoryModel.php @@ -25,7 +25,7 @@ class InventoryCategoryModel extends Model 'school_year', ]; protected $validationRules = [ - 'school_year' => 'required|string|max_length[9]', + 'school_year' => 'permit_empty|string|max_length[9]', ]; diff --git a/app/Models/SchoolYearModel.php b/app/Models/SchoolYearModel.php index bd7b50a..c8d6c71 100644 --- a/app/Models/SchoolYearModel.php +++ b/app/Models/SchoolYearModel.php @@ -25,9 +25,7 @@ class SchoolYearModel extends Model 'administrative_exceptions_permitted', 'registration_exception_roles', 'adult_student_registration_enabled', - 'registration_fee', 'tuition_due_at_registration', - 'mandatory_fees', 'carry_over_balance_behavior', 'financial_policy_message', 'payment_plan_available', @@ -60,9 +58,7 @@ class SchoolYearModel extends Model 'late_registration_blocked' => 'permit_empty|in_list[0,1]', 'administrative_exceptions_permitted' => 'permit_empty|in_list[0,1]', 'adult_student_registration_enabled' => 'permit_empty|in_list[0,1]', - 'registration_fee' => 'permit_empty|decimal', 'tuition_due_at_registration' => 'permit_empty|decimal', - 'mandatory_fees' => 'permit_empty|decimal', 'carry_over_balance_behavior' => 'permit_empty|in_list[information_only,payment_plan_required,submission_allowed_confirmation_blocked,submission_blocked_until_payment,admin_approval_required]', 'payment_plan_available' => 'permit_empty|in_list[0,1]', 'registration_launch_approved_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', diff --git a/app/Services/EnrollmentRegistrationEmailService.php b/app/Services/EnrollmentRegistrationEmailService.php index feb16dd..b051e4c 100644 --- a/app/Services/EnrollmentRegistrationEmailService.php +++ b/app/Services/EnrollmentRegistrationEmailService.php @@ -116,15 +116,31 @@ final class EnrollmentRegistrationEmailService foreach ($this->recipientFamilies($schoolYear, null) as $family) { $message = $this->buildMessage($schoolYear, $family); $latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']); + $previousYear = $this->previousSchoolYearName($schoolYearName); + $studentSummaries = []; + + foreach ($family['students'] as $student) { + $studentId = (int) ($student['id'] ?? 0); + $evaluation = $studentId > 0 && $previousYear !== null + ? $this->transitionService->evaluateForParent( + (int) $family['parent_user_id'], + $studentId, + $previousYear, + $schoolYearName + ) + : []; + $studentSummaries[] = [ + 'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $studentId, + 'adult_student' => ! empty($evaluation['adult_student']), + ]; + } $examples[] = [ 'parent_user_id' => (int) $family['parent_user_id'], 'parent_name' => (string) $family['name'], 'recipients' => $message['recipients'], - 'student_names' => array_map( - static fn (array $student): string => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . (int) ($student['id'] ?? 0), - $family['students'] - ), + 'students' => $studentSummaries, + 'student_names' => array_column($studentSummaries, 'name'), 'subject' => (string) $message['subject'], 'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'), 'sent_at' => $latest['sent_at'] ?? null, @@ -161,10 +177,15 @@ final class EnrollmentRegistrationEmailService continue; } - $evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent'); + $evaluation = $this->transitionService->evaluateForParent( + (int) $family['parent_user_id'], + $studentId, + $previousYear, + $schoolYearName + ); $studentRows[] = $this->studentRow($student, $evaluation, $opens, $deadline, $schoolYear); $studentIds[] = $studentId; - $hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || $this->canCompleteParentReEnrollment($evaluation); + $hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || (bool) ($evaluation['can_enroll'] ?? false); } $financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear); @@ -192,7 +213,7 @@ final class EnrollmentRegistrationEmailService private function canCompleteParentReEnrollment(array $evaluation): bool { - return (bool) ($evaluation['parent_enrollment_allowed'] ?? false); + return (bool) ($evaluation['can_enroll'] ?? false); } private function registrationStepsSection(string $opens, string $deadline, bool $include): string @@ -227,7 +248,7 @@ final class EnrollmentRegistrationEmailService $requiredAction = $this->requiredAction($evaluation, $deadline, $name); $message = $this->decisionMessage($name, $evaluation, $opens, $deadline, $schoolYear); $nextStepHtml = $this->decisionMessageHtml($message, (string) ($evaluation['deliberation_decision'] ?? '')); - if ($requiredAction !== '') { + if ($this->shouldAppendRequiredAction($message, $requiredAction)) { $nextStepHtml .= '
' . esc($requiredAction) . ''; } @@ -239,7 +260,7 @@ final class EnrollmentRegistrationEmailService . '' . '' . $this->studentDetailRow('Decision', esc($decision ?: 'Pending')) - . $this->studentDetailRow('Registration Status', '' . esc($status) . '
' . esc($placement)) + . $this->studentDetailRow('Registration Status', '' . esc($status) . '' . ($placement !== '' ? '
' . esc($placement) : '')) . $this->studentDetailRow('Next Step', $nextStepHtml) . ''; } @@ -272,14 +293,15 @@ final class EnrollmentRegistrationEmailService private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string { - if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { - $grade = $this->currentGradeText($evaluation); - - return $name . ' has successfully passed ' . $grade . '.'; + if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) { + return (string) $evaluation['primary_parent_message']; } if (($evaluation['blockers'] ?? []) !== []) { - return implode(' ', array_map('strval', $evaluation['blockers'])); + return implode(' ', array_values(array_unique(array_filter(array_map( + static fn ($blocker): string => trim((string) $blocker), + $evaluation['blockers'] + ))))); } return match ((string) ($evaluation['deliberation_decision'] ?? '')) { @@ -318,11 +340,13 @@ final class EnrollmentRegistrationEmailService private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string { - $carry = $previousYear !== null ? $this->invoiceBalance($parentId, $previousYear) : 0.0; - $registrationFee = (float) ($schoolYear['registration_fee'] ?? 0); - $tuition = (float) ($schoolYear['tuition_due_at_registration'] ?? 0); - $mandatory = (float) ($schoolYear['mandatory_fees'] ?? 0); - $total = max(0, $carry) + $registrationFee + $tuition + $mandatory; + $schoolYearName = (string) ($schoolYear['name'] ?? ''); + $summary = $this->transitionService->getEnrollmentFinancialSummary( + $parentId, + $previousYear ?? '', + $schoolYearName + ); + $total = (float) ($summary['total_enrollment_due'] ?? $summary['amount_due'] ?? 0); if ($total <= 0.0) { return ''; } @@ -333,7 +357,7 @@ final class EnrollmentRegistrationEmailService } return '

Family Account Information

' - . '

Carry-over balance: $' . number_format($carry, 2) . '
' + . '

Carry-over balance: $' . number_format((float) ($summary['carry_forward_balance'] ?? 0), 2) . '
' . 'Total currently due: $' . number_format($total, 2) . '

' . '

' . esc($message) . '

'; } @@ -488,40 +512,73 @@ final class EnrollmentRegistrationEmailService private function registrationStatus(array $evaluation): string { - if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { - return 'Eligible'; + if (($evaluation['can_enroll'] ?? false) === true) { + return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM + ? 'Eligible with pending placement' + : 'Eligible'; + } + + if (! empty($evaluation['adult_student'])) { + return 'Not Eligible'; } if (($evaluation['blockers'] ?? []) !== []) { - return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible'; + return 'Not Eligible'; } - return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible'; + + return 'Not Eligible'; } private function placementText(array $evaluation): string { - return match ((string) ($evaluation['placement_status'] ?? '')) { + $placement = match ((string) ($evaluation['placement_status'] ?? '')) { 'automatic_distribution_pending' => $this->assignedGradeText($evaluation), 'same_class_assigned' => 'Same grade and class', 'temporary_same_grade' => 'Same grade initially', 'manual_class_required' => 'Same grade', 'exit_required' => 'Completion or exit process required', - default => 'Pending', + default => '', }; + + if ($placement !== '') { + return $placement; + } + + return ($evaluation['can_enroll'] ?? false) === true ? 'Pending' : ''; } private function requiredAction(array $evaluation, string $deadline, string $name = 'The student'): string { - if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { - return 'Complete re-enrollment before ' . $deadline . '.'; + if (($evaluation['can_enroll'] ?? false) === true) { + return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM + ? '' + : 'Complete re-enrollment before ' . $deadline . '.'; } - if (($evaluation['blockers'] ?? []) !== []) { - return ($evaluation['adult_student'] ?? false) ? 'Student must complete the authorized adult-student process or contact administration.' : 'Contact the school administration.'; + if (! empty($evaluation['primary_parent_message'])) { + return (string) $evaluation['primary_parent_message']; } - return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM - ? '' - : 'Complete re-enrollment before ' . $deadline . '.'; + + return 'Contact the school administration.'; + } + + private function shouldAppendRequiredAction(string $message, string $requiredAction): bool + { + $message = trim($message); + $requiredAction = trim($requiredAction); + if ($requiredAction === '') { + return false; + } + if ($message === '') { + return true; + } + + $normalizedMessage = preg_replace('/\s+/', ' ', strtolower($message)) ?? $message; + $normalizedAction = preg_replace('/\s+/', ' ', strtolower($requiredAction)) ?? $requiredAction; + + return $normalizedMessage !== $normalizedAction + && ! str_contains($normalizedMessage, $normalizedAction) + && ! str_contains($normalizedAction, $normalizedMessage); } private function previousSchoolYearName(string $schoolYear): ?string diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php index 4f089f3..6ad8c1a 100644 --- a/app/Services/EnrollmentTransitionService.php +++ b/app/Services/EnrollmentTransitionService.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Libraries\FinancialStatus; +use App\Libraries\InvoiceLedgerService; use App\Support\Enrollment\DeliberationDecision; use App\Support\Enrollment\EnrollmentEligibility; use CodeIgniter\Database\BaseConnection; @@ -18,15 +20,26 @@ final class EnrollmentTransitionService private const DECISION_EXCEPTION_ELIGIBLE = 'EXCEPTION_ELIGIBLE'; private const DECISION_ALREADY_ENROLLED = 'ALREADY_ENROLLED'; + /** Admin exceptions may override every eligibility rule except adult-student age. */ private const NON_OVERRIDABLE_RULE_CODES = [ - 'STUDENT_NOT_LINKED', - 'SOURCE_YEAR_NOT_FOUND', - 'TARGET_YEAR_NOT_FOUND', - 'ALREADY_ENROLLED', + 'ADULT_STUDENT_PARENT_BLOCKED', ]; - public function __construct(private readonly BaseConnection $db) - { + /** @var list */ + private const BLOCKING_BALANCE_BEHAVIORS = [ + 'submission_blocked_until_payment', + 'admin_approval_required', + ]; + + /** @var array */ + private array $resolvedInvoiceBalanceCache = []; + + private ?InvoiceLedgerService $invoiceLedgerService = null; + + public function __construct( + private readonly BaseConnection $db, + private readonly ?EnrollmentStatusService $statusService = null, + ) { } public function evaluateForParent( @@ -53,9 +66,9 @@ final class EnrollmentTransitionService $this->addBlocker($evaluation, 'SOURCE_YEAR_NOT_FOUND', 'Student does not belong to the closing school year.'); } - $existing = $this->latestEnrollment($studentId, $targetSchoolYear); + $existing = $this->controllingEnrollment($studentId, $targetSchoolYear); if ($this->activeEnrollmentBlocksDuplicate($existing)) { - $this->addBlocker($evaluation, 'ALREADY_ENROLLED', 'Student already has an active enrollment for the selected school year.'); + $this->addBlocker($evaluation, 'ALREADY_ENROLLED', EnrollmentEligibility::ALREADY_ENROLLED_MESSAGE); } elseif ($this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($existing)) { $code = strtolower((string) ($existing['admission_status'] ?? '')) === 'denied' || strtolower((string) ($existing['enrollment_status'] ?? '')) === 'denied' @@ -70,11 +83,115 @@ final class EnrollmentTransitionService $this->deriveAcademicRuleCodes($evaluation); $this->applyScopedException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear); $this->finalizeParentDecision($evaluation); + $this->assignPrimaryBlockerFields($evaluation); $this->appendFlagsFromRuleCodes($evaluation); return $evaluation; } + /** + * @return list + */ + public function statusesRequiringEnrollmentEligibility(): array + { + return [ + 'admission under review', + 'payment pending', + 'enrolled', + 'waitlist', + ]; + } + + public function evaluateEnrollmentAdvance( + int $parentId, + int $studentId, + string $targetSchoolYear, + string $actorRole = 'admin' + ): array { + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return [ + 'student_id' => $studentId, + 'target_school_year' => $targetSchoolYear, + 'can_enroll' => false, + 'primary_block_reason' => 'SOURCE_YEAR_NOT_FOUND', + 'primary_parent_message' => 'Source school year could not be resolved for eligibility.', + 'blocking_rule_codes' => ['SOURCE_YEAR_NOT_FOUND'], + ]; + } + + return $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); + } + + /** + * Admin operations (class assignment, status advance) may proceed when the student + * is already enrolled for the target year, or when an admin exception authorizes them. + * Adult-student blocking remains non-overridable. + */ + public function adminMayAdvanceEnrollment(array $evaluation): bool + { + if (in_array('ADULT_STUDENT_PARENT_BLOCKED', array_map('strval', $evaluation['blocking_rule_codes'] ?? []), true)) { + return false; + } + + if (($evaluation['can_enroll'] ?? false) === true) { + return true; + } + + $decision = (string) ($evaluation['decision'] ?? ''); + if ($decision === self::DECISION_ALREADY_ENROLLED || $decision === self::DECISION_EXCEPTION_ELIGIBLE) { + return true; + } + + return ($evaluation['admin_exception'] ?? null) !== null; + } + + public function logEnrollmentBlock( + array $evaluation, + string $context, + ?int $parentId = null, + ?int $performedBy = null + ): void { + $payload = [ + 'context' => $context, + 'student_id' => (int) ($evaluation['student_id'] ?? 0), + 'parent_id' => $parentId ?? ($evaluation['parent_id'] ?? null), + 'school_year' => (string) ($evaluation['target_school_year'] ?? ''), + 'enrollment_status' => $evaluation['enrollment_status'] ?? null, + 'academic_decision' => $evaluation['deliberation_decision'] ?? null, + 'age' => $evaluation['age_on_reference_date'] ?? null, + 'adult_student' => (bool) ($evaluation['adult_student'] ?? false), + 'blocking_rules' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])), + 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, + 'exception_ids' => isset($evaluation['admin_exception']['id']) ? [(int) $evaluation['admin_exception']['id']] : [], + 'financial_summary' => $evaluation['financial_summary'] ?? null, + 'timestamp' => date('Y-m-d H:i:s'), + ]; + + log_message('warning', 'Enrollment blocked [{context}] student={student_id} parent={parent_id} reason={primary_block_reason}', [ + 'context' => $context, + 'student_id' => $payload['student_id'], + 'parent_id' => $payload['parent_id'], + 'primary_block_reason' => $payload['primary_block_reason'] ?? 'unknown', + ]); + + if ($payload['student_id'] <= 0 || ! $this->db->tableExists('enrollment_transition_audits')) { + return; + } + + $this->db->table('enrollment_transition_audits')->insert([ + 'student_id' => $payload['student_id'], + 'school_year' => $payload['school_year'] !== '' ? $payload['school_year'] : date('Y'), + 'source_school_year' => $evaluation['source_school_year'] ?? null, + 'action' => 'enrollment_blocked', + 'performed_by' => $performedBy, + 'original_values_json' => null, + 'new_values_json' => json_encode($payload, JSON_UNESCAPED_SLASHES), + 'reason' => $context, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + public function markExceptionUsed(int $exceptionId, int $enrollmentId): void { if ($exceptionId <= 0 || $enrollmentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { @@ -165,11 +282,46 @@ final class EnrollmentTransitionService return $result; } + $sourceEnrollment = $this->db->tableExists('enrollments') + ? $this->controllingEnrollment($studentId, $sourceSchoolYear) + : null; + if (is_array($sourceEnrollment)) { + $student['enrollment_status'] = $sourceEnrollment['enrollment_status'] ?? ($student['enrollment_status'] ?? null); + $student['is_withdrawn'] = $sourceEnrollment['is_withdrawn'] ?? ($student['is_withdrawn'] ?? 0); + } + + $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); + $result['age_on_reference_date'] = $age; + $result['adult_student'] = $age !== null && $age >= EnrollmentEligibility::ADULT_STUDENT_MIN_AGE; + + if ($decision === DeliberationDecision::EXPELLED) { + $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; + $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']); + return $result; + } + + if ($result['adult_student']) { + $this->applyAdultStudentBlock($result, $student, $age); + return $result; + } + + if ($decision === DeliberationDecision::WITHDRAWN || EnrollmentEligibility::isMarkedWithdrawn($student)) { + $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; + $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']); + return $result; + } + if ($sourceAssignment === null) { $result['blockers'][] = 'Student does not belong to the closing school year.'; return $result; } + if ($decision === DeliberationDecision::DEFERRED_DECISION) { + $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; + $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']); + return $result; + } + if ($decisionRow === null || $decision === null) { $result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE; $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [ @@ -179,24 +331,6 @@ final class EnrollmentTransitionService return $result; } - if ($decision === DeliberationDecision::EXPELLED) { - $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; - $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']); - return $result; - } - - if ($decision === DeliberationDecision::WITHDRAWN) { - $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; - $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']); - return $result; - } - - if ($decision === DeliberationDecision::DEFERRED_DECISION) { - $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; - $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']); - return $result; - } - $placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear); $result = array_replace($result, $placement); $result['academic_eligible'] = $placement['placement_status'] !== 'exit_required'; @@ -208,21 +342,8 @@ final class EnrollmentTransitionService ]); } - $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); - $result['age_on_reference_date'] = $age; - $result['adult_student'] = $age !== null && $age >= 18; $result['parent_enrollment_allowed'] = $result['academic_eligible']; - $result['student_self_enrollment_allowed'] = $result['academic_eligible'] && (bool) ($targetYear['adult_student_registration_enabled'] ?? false); - - if ($result['adult_student']) { - $result['parent_enrollment_allowed'] = false; - $result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ - 'age_on_reference_date' => $age, - ]); - if ($actorRole === 'parent') { - $result['blockers'][] = str_replace('The student', $this->studentName($student), EnrollmentEligibility::ADULT_STUDENT_MESSAGE); - } - } + $result['student_self_enrollment_allowed'] = false; $this->applyRegistrationWindow($result, $targetYear, $now, $actorRole); $this->applyAgeRules($result, $targetSchoolYear, $age); @@ -249,15 +370,25 @@ final class EnrollmentTransitionService string $actorRole = 'admin' ): array { $evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); - if (! $evaluation['academic_eligible'] || $evaluation['blockers'] !== []) { + $student = $this->student($studentId); + $parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : 0); + if ($parentId > 0) { + $evaluation = $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); + } + + $this->ensureDecisionFields($evaluation); + $this->assignPrimaryBlockerFields($evaluation); + + if (($evaluation['can_enroll'] ?? false) !== true) { $this->writeFlags($evaluation, $performedBy); + $this->logEnrollmentBlock($evaluation, 'apply_initial_transition', $parentId > 0 ? $parentId : null, $performedBy); $this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation); + return $evaluation; } $this->db->transStart(); - $original = $this->latestEnrollment($studentId, $targetSchoolYear); - $student = $this->student($studentId); + $original = $this->controllingEnrollment($studentId, $targetSchoolYear); $parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null); $payload = [ @@ -332,15 +463,20 @@ final class EnrollmentTransitionService } if ($decision === DeliberationDecision::REPEAT_CLASS) { + $sourceSectionId = (int) ($sourceAssignment['class_section_id'] ?? 0); $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); + if ($targetSection === null && $sourceSectionId > 0) { + $derivedSectionId = $this->repeatClassBaseSectionId($sourceSectionId); + if ($derivedSectionId > 0) { + $targetSection = $this->sectionByClassSectionId($derivedSectionId, $targetSchoolYear); + } + } + $flags = []; if ($targetSection === null) { $flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [ 'previous_class_section_name' => $sourceSectionName, - ]); - } elseif ($this->sectionAtCapacity((int) $targetSection['class_section_id'])) { - $flags[] = $this->flag('CLASS_CAPACITY_EXCEPTION_REQUIRED', 'normal', [ - 'class_section_id' => (int) $targetSection['class_section_id'], + 'previous_class_section_id' => $sourceSectionId > 0 ? $sourceSectionId : null, ]); } @@ -628,40 +764,165 @@ final class EnrollmentTransitionService return $builder->limit(1)->get()->getRowArray() ?: null; } - private function sectionAtCapacity(int $classSectionId): bool + /** + * Repeat-class students keep the same grade. Section codes use the units digit for + * lettered sections (e.g. 41 = 4-A, 40 = grade 4 base). Zeroing that digit yields the + * base grade section to assign before redistribution. + */ + private function repeatClassBaseSectionId(int $sourceClassSectionId): int { - $section = $this->db->table('classSection cs') - ->select('cs.class_section_id, c.capacity') - ->join('classes c', 'c.id = cs.class_id', 'left') - ->where('cs.class_section_id', $classSectionId) - ->orderBy('cs.id', 'DESC') - ->limit(1) - ->get() - ->getRowArray(); - - $capacity = is_numeric($section['capacity'] ?? null) ? (int) $section['capacity'] : 0; - if ($capacity <= 0) { - return false; + if ($sourceClassSectionId <= 0) { + return 0; } - $count = $this->db->table('student_class')->where('class_section_id', $classSectionId)->countAllResults(); - return $count >= $capacity; + if ($sourceClassSectionId < 10) { + return $sourceClassSectionId; + } + + return intdiv($sourceClassSectionId, 10) * 10; } - private function latestEnrollment(int $studentId, string $schoolYear): ?array + private function sectionByClassSectionId(int $classSectionId, string $targetSchoolYear): ?array { - if (! $this->db->tableExists('enrollments')) { + if ($classSectionId <= 0) { return null; } - return $this->db->table('enrollments') + $builder = $this->db->table('classSection') + ->select('class_section_id, class_id, class_section_name') + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC'); + + if ($this->db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $targetSchoolYear); + } + + return $builder->limit(1)->get()->getRowArray() ?: null; + } + + private function syncRepeatClassReassignmentFlags( + string $targetSchoolYear, + string $sourceSchoolYear, + ?int $performedBy + ): int { + if (! $this->db->tableExists('enrollment_flags')) { + return 0; + } + + $openFlags = $this->db->table('enrollment_flags') + ->where('school_year', $targetSchoolYear) + ->where('flag_type', 'CLASS_REASSIGNMENT_REQUIRED') + ->where('status', 'open') + ->get() + ->getResultArray(); + + $resolved = 0; + foreach ($openFlags as $flagRow) { + $studentId = (int) ($flagRow['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + $sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear); + $sourceSectionId = (int) ($sourceAssignment['class_section_id'] ?? 0); + if ($sourceSectionId <= 0) { + continue; + } + + $derivedSectionId = $this->repeatClassBaseSectionId($sourceSectionId); + $targetSection = $derivedSectionId > 0 + ? $this->sectionByClassSectionId($derivedSectionId, $targetSchoolYear) + : null; + if ($targetSection === null) { + continue; + } + + $targetSectionId = (int) ($targetSection['class_section_id'] ?? 0); + if ($targetSectionId <= 0) { + continue; + } + + $this->applyRepeatClassSectionAssignment($studentId, $targetSchoolYear, $targetSection, $performedBy); + + $this->db->table('enrollment_flags') + ->where('id', (int) ($flagRow['id'] ?? 0)) + ->update([ + 'status' => 'resolved', + 'resolved_at' => date('Y-m-d H:i:s'), + 'resolution_notes' => 'Automatically assigned to base grade section ' . $targetSectionId + . ' derived from previous year section ' . $sourceSectionId . '.', + ]); + + $resolved++; + } + + return $resolved; + } + + private function applyRepeatClassSectionAssignment( + int $studentId, + string $targetSchoolYear, + array $targetSection, + ?int $performedBy + ): void { + $sectionId = (int) ($targetSection['class_section_id'] ?? 0); + if ($sectionId <= 0) { + return; + } + + $this->upsertStudentClass($studentId, $sectionId, $targetSchoolYear, $performedBy); + + if (! $this->db->tableExists('enrollments')) { + return; + } + + $enrollment = $this->db->table('enrollments') ->where('student_id', $studentId) - ->where('school_year', $schoolYear) + ->where('school_year', $targetSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() - ->getRowArray() ?: null; + ->getRowArray(); + + if ($enrollment === null) { + return; + } + + $this->db->table('enrollments') + ->where('id', (int) ($enrollment['id'] ?? 0)) + ->update([ + 'class_section_id' => $sectionId, + 'assigned_class_section_id' => $sectionId, + 'assigned_grade_id' => (int) ($targetSection['class_id'] ?? 0) ?: null, + 'placement_status' => 'same_class_assigned', + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + private function assignPrimaryBlockerFields(array &$evaluation): void + { + if (($evaluation['can_enroll'] ?? false) === true) { + $evaluation['primary_block_reason'] = null; + $evaluation['primary_parent_message'] = null; + + return; + } + + $blockers = array_values(array_unique(array_merge( + array_map('strval', $evaluation['blocking_rule_codes'] ?? []), + array_map('strval', $evaluation['review_rule_codes'] ?? []) + ))); + $primary = EnrollmentEligibility::selectPrimaryBlocker($blockers); + $evaluation['primary_block_reason'] = $primary; + $evaluation['primary_parent_message'] = $primary !== null + ? EnrollmentEligibility::messageForRuleCode($primary) + : null; + } + + private function latestEnrollment(int $studentId, string $schoolYear): ?array + { + return $this->controllingEnrollment($studentId, $schoolYear); } private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void @@ -706,7 +967,6 @@ final class EnrollmentTransitionService return 0; } - $decisions = $this->latestDecisionsByStudent($sourceSchoolYear); $enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear); $targetReviewCodes = $this->targetEnrollmentReviewCodesByStudent($targetSchoolYear); $bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear); @@ -718,13 +978,17 @@ final class EnrollmentTransitionService continue; } - $decisionRow = $decisions[$studentId] ?? null; - $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) - ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); $alreadyEnrolled = isset($enrolledIds[$studentId]); + $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); + $isAdult = $age !== null && $age >= EnrollmentEligibility::ADULT_STUDENT_MIN_AGE; $flags = []; - if (! $alreadyEnrolled && isset($targetReviewCodes[$studentId])) { + if (! $alreadyEnrolled && $isAdult) { + $flags[] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ + 'age_on_reference_date' => $age, + 'rule_code' => 'ADULT_STUDENT_PARENT_BLOCKED', + ]); + } elseif (! $alreadyEnrolled && isset($targetReviewCodes[$studentId])) { $review = $targetReviewCodes[$studentId]; $code = (string) ($review['rule_code'] ?? 'WITHDRAWN'); $flags[] = $this->flag( @@ -736,31 +1000,20 @@ final class EnrollmentTransitionService 'is_withdrawn' => $review['is_withdrawn'] ?? null, ] ); - } elseif ($decisionRow === null || $decision === null) { - if (! $alreadyEnrolled) { - $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', [ - 'rule_code' => $decisionRow === null ? 'NO_FINAL_DECISION' : 'UNRECOGNIZED_DECISION', - 'reason' => 'Missing or unrecognized final deliberation decision.', - ]); - } - } elseif ($decision === DeliberationDecision::EXPELLED && ! $alreadyEnrolled) { - $flags[] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']); - } elseif ($decision === DeliberationDecision::WITHDRAWN && ! $alreadyEnrolled) { - $flags[] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']); - } elseif ($decision === DeliberationDecision::DEFERRED_DECISION && ! $alreadyEnrolled) { - $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']); - } elseif ($decision === DeliberationDecision::MAKE_UP_EXAM) { - $flags[] = $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [ - 'rule_code' => 'MAKE_UP_EXAM', - 'current_class_section_name' => $student['class_section_name'] ?? ($decisionRow['class_section_name'] ?? null), - ]); - } + } else { + $parentId = (int) ($student['parent_id'] ?? 0); + $evaluation = $parentId > 0 + ? $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, 'admin') + : $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, 'admin'); - $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); - if ($age !== null && $age >= 18 && ! $alreadyEnrolled) { - $flags[] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ - 'age_on_reference_date' => $age, - ]); + if ($alreadyEnrolled) { + $flags = array_values(array_filter( + $evaluation['flags'] ?? [], + static fn (array $flag): bool => ($flag['flag_type'] ?? '') === 'PENDING_MAKE_UP_EXAM_PROMOTION' + )); + } else { + $flags = $evaluation['flags'] ?? []; + } } foreach ($flags as $flag) { @@ -771,10 +1024,16 @@ final class EnrollmentTransitionService $written++; } } + + if ($isAdult) { + $this->retireWithdrawnFlagsForAdultStudent($studentId, $targetSchoolYear); + } } $written += $this->syncSiblingLastNameFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy); $written += $this->syncFinancialFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy); + $written += $this->syncRepeatClassReassignmentFlags($targetSchoolYear, $sourceSchoolYear, $performedBy); + $this->retireClassCapacityExceptionFlags($targetSchoolYear); return $written; } @@ -804,7 +1063,7 @@ final class EnrollmentTransitionService } $flagType = (string) ($flag['flag_type'] ?? ''); - if ($flagType === '') { + if ($flagType === '' || $flagType === 'CLASS_CAPACITY_EXCEPTION_REQUIRED') { return false; } @@ -836,6 +1095,44 @@ final class EnrollmentTransitionService return true; } + private function retireWithdrawnFlagsForAdultStudent(int $studentId, string $targetSchoolYear): void + { + if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('enrollment_flags')) { + return; + } + + $this->db->table('enrollment_flags') + ->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->where('flag_type', 'WITHDRAWAL_REVIEW_REQUIRED') + ->where('status', 'open') + ->update([ + 'status' => 'resolved', + 'resolved_at' => date('Y-m-d H:i:s'), + 'resolution_notes' => 'Adult-student age rule takes priority over withdrawal review.', + ]); + } + + private function retireClassCapacityExceptionFlags(string $targetSchoolYear): void + { + if (! $this->db->tableExists('enrollment_flags')) { + return; + } + + $builder = $this->db->table('enrollment_flags') + ->where('flag_type', 'CLASS_CAPACITY_EXCEPTION_REQUIRED') + ->where('status', 'open'); + if ($targetSchoolYear !== '') { + $builder->where('school_year', $targetSchoolYear); + } + + $builder->update([ + 'status' => 'resolved', + 'resolved_at' => date('Y-m-d H:i:s'), + 'resolution_notes' => 'Class capacity exceptions are no longer used. Class assignment is handled by section autogeneration or enrollment management.', + ]); + } + private function activeBypassCodesByStudent(string $schoolYear): array { if ($schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) { @@ -887,7 +1184,6 @@ final class EnrollmentTransitionService 'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED', 'AGE_EXCEPTION_REQUIRED'], 'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED', 'LATE_REGISTRATION_EXCEPTION'], 'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED', 'FINANCIAL_REVIEW_REQUIRED'], - 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_REQUIRED'], 'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'], 'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED'], 'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION', 'DEFERRED_DELIBERATION'], @@ -1145,21 +1441,16 @@ final class EnrollmentTransitionService return 0; } - $balances = $this->db->table('invoices') - ->select('parent_id, COALESCE(SUM(balance), 0) AS balance', false) - ->where('school_year', $sourceSchoolYear) - ->whereIn('parent_id', array_values($parentIds)) - ->groupBy('parent_id') - ->get() - ->getResultArray(); - $flagType = $behavior === 'admin_approval_required' ? 'FINANCIAL_REVIEW_REQUIRED' : 'FINANCIAL_REVIEW_REQUIRED'; $written = 0; - foreach ($balances as $row) { - if ((float) ($row['balance'] ?? 0) <= 0.0) { + foreach (array_values($parentIds) as $parentId) { + $summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); + $balance = (float) ($summary['carry_forward_balance'] ?? 0.0); + if ($balance <= 0.0) { + $this->resolveOpenFinancialReviewFlagsForParent($parentId, $targetSchoolYear, $studentsByParent[$parentId] ?? []); + continue; } - $parentId = (int) ($row['parent_id'] ?? 0); foreach ($studentsByParent[$parentId] ?? [] as $studentId) { if (isset($enrolledIds[$studentId])) { continue; @@ -1171,7 +1462,7 @@ final class EnrollmentTransitionService $studentId, $targetSchoolYear, $sourceSchoolYear, - $this->flag($flagType, 'high', ['carry_over_balance' => (float) $row['balance']]), + $this->flag($flagType, 'high', ['carry_over_balance' => $balance]), $performedBy )) { $written++; @@ -1182,6 +1473,125 @@ final class EnrollmentTransitionService return $written; } + public function resolveOpenFinancialReviewFlagsForParent(int $parentId, string $schoolYear, array $studentIds = []): int + { + if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) { + return 0; + } + + if ($studentIds === []) { + $studentIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $this->linkedStudentsForParent($parentId) + ))); + } + + $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds)))); + if ($studentIds === []) { + return 0; + } + + $this->db->table('enrollment_flags') + ->whereIn('student_id', $studentIds) + ->where('school_year', $schoolYear) + ->where('flag_type', 'FINANCIAL_REVIEW_REQUIRED') + ->where('status', 'open') + ->update([ + 'status' => 'resolved', + 'resolved_at' => date('Y-m-d H:i:s'), + 'resolution_notes' => 'Automatically resolved because no outstanding carry-forward balance remains.', + ]); + + return (int) $this->db->affectedRows(); + } + + public function syncParentFinancialReviewFlags( + int $parentId, + string $sourceSchoolYear, + string $targetSchoolYear, + array $studentIds = [] + ): void { + $summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); + if ((float) ($summary['carry_forward_balance'] ?? 0.0) > 0.0) { + return; + } + + $this->resolveOpenFinancialReviewFlagsForParent($parentId, $targetSchoolYear, $studentIds); + } + + /** + * Refresh stored invoice balances after payment so enrollment and finance stay aligned. + */ + public function reconcileCarryForwardInvoicesAfterSourcePayment(int $parentId, string $schoolYear): int + { + return $this->refreshParentInvoiceBalances($parentId, $schoolYear); + } + + public function refreshParentInvoiceBalances(int $parentId, string $schoolYear): int + { + if ($parentId <= 0 || ! $this->db->tableExists('invoices')) { + return 0; + } + + $ledger = $this->invoiceLedger(); + $recalculated = 0; + $invoiceIds = []; + + if ($schoolYear !== '') { + $rows = $this->db->table('invoices') + ->select('id') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0); + } + } + + $carryForwardRows = $this->db->table('invoices') + ->select('id, invoice_number, description') + ->where('parent_id', $parentId) + ->get() + ->getResultArray(); + $sourceToken = $schoolYear !== '' ? (preg_replace('/[^0-9A-Za-z]/', '', $schoolYear) ?? '') : ''; + foreach ($carryForwardRows as $row) { + if (! $this->isCarryForwardInvoiceRow($row)) { + continue; + } + + if ($schoolYear === '') { + $invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0); + continue; + } + + $invoiceNumber = (string) ($row['invoice_number'] ?? ''); + $description = (string) ($row['description'] ?? ''); + if ( + ($sourceToken !== '' && str_contains($invoiceNumber, $sourceToken)) + || str_contains($description, $schoolYear) + ) { + $invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0); + } + } + + foreach (array_values(array_filter($invoiceIds)) as $invoiceId) { + if ($invoiceId <= 0) { + continue; + } + + try { + $ledger->recalculateInvoice($invoiceId); + unset($this->resolvedInvoiceBalanceCache[$invoiceId]); + $recalculated++; + } catch (\Throwable) { + // Leave enrollment logic to fall back to stored balances for this invoice. + } + } + + return $recalculated; + } + private function appendFlagsFromRuleCodes(array &$evaluation): void { $existing = []; @@ -1257,6 +1667,23 @@ final class EnrollmentTransitionService ]); } + private function applyAdultStudentBlock(array &$result, array $student, ?int $age): void + { + $result['age_on_reference_date'] = $age; + $result['adult_student'] = true; + $result['parent_enrollment_allowed'] = false; + $result['student_self_enrollment_allowed'] = false; + $result['academic_eligible'] = false; + $result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ + 'age_on_reference_date' => $age, + 'rule_code' => 'ADULT_STUDENT_PARENT_BLOCKED', + ]); + $name = $this->studentName($student); + $result['blockers'][] = $name === 'The student' + ? EnrollmentEligibility::ADULT_STUDENT_MESSAGE + : str_replace('This student', $name, EnrollmentEligibility::ADULT_STUDENT_MESSAGE); + } + private function flag(string $type, string $priority, array $details = []): array { return ['flag_type' => $type, 'priority' => $priority, 'details' => $details]; @@ -1350,7 +1777,7 @@ final class EnrollmentTransitionService str_contains($lower, 'deferred') => 'DEFERRED_DECISION', str_contains($lower, 'no final deliberation') || str_contains($lower, 'no final academic') || str_contains($lower, 'no final decision') => 'NO_FINAL_DECISION', str_contains($lower, 'unrecognized') => 'UNRECOGNIZED_DECISION', - str_contains($lower, '18 years old') || str_contains($lower, 'adult-student') => 'ADULT_STUDENT_PARENT_BLOCKED', + str_contains($lower, '18 years old') || str_contains($lower, 'adult-student') || str_contains($lower, 'over the allowed age') => 'ADULT_STUDENT_PARENT_BLOCKED', str_contains($lower, 'registration for the new school year has not opened') => 'REGISTRATION_NOT_OPEN', str_contains($lower, 'registration deadline') => 'REGISTRATION_CLOSED', str_contains($lower, 'age rule') => 'AGE_RULE_BLOCKED', @@ -1455,8 +1882,8 @@ final class EnrollmentTransitionService $admission = strtolower(trim((string) ($enrollment['admission_status'] ?? ''))); return $admission === 'denied' - || in_array($status, ['denied', 'withdrawn'], true) - || (int) ($enrollment['is_withdrawn'] ?? 0) === 1; + || $status === 'denied' + || EnrollmentEligibility::isMarkedWithdrawn($enrollment); } private function applyHouseholdLastNameRule(array &$evaluation, int $parentId): void @@ -1644,63 +2071,259 @@ final class EnrollmentTransitionService return trim($value); } - private function applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void + private function controllingEnrollment(int $studentId, string $schoolYear): ?array { - $summary = $this->financialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); - $evaluation['financial_summary'] = $summary; - - $balance = (float) ($summary['carry_over_balance'] ?? 0.0); - $behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'); - if ($balance <= 0.0) { - return; - } - - if ($behavior === 'admin_approval_required') { - $this->addBlocker($evaluation, 'FINANCE_APPROVAL_REQUIRED', 'Registration requires administrative financial approval because there is a previous-year balance.'); - return; - } - - $this->addBlocker($evaluation, 'OUTSTANDING_BALANCE_BLOCKED', 'Registration cannot be submitted until the previous-year balance is paid.'); + return $this->enrollmentStatusService()->controllingEnrollment($studentId, $schoolYear); } - private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): array + private function enrollmentStatusService(): EnrollmentStatusService + { + return $this->statusService ?? new EnrollmentStatusService($this->db); + } + + /** + * @return array + */ + public function getEnrollmentFinancialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear, float $tuitionDue = 0.0): array { $target = $this->schoolYearByName($targetSchoolYear) ?? []; - $carryOver = $this->invoiceBalanceForParent($parentId, $sourceSchoolYear); - $current = $this->invoiceBalanceForParent($parentId, $targetSchoolYear); - $registrationFee = round((float) ($target['registration_fee'] ?? 0), 2); - $tuitionDue = round((float) ($target['tuition_due_at_registration'] ?? 0), 2); - $mandatory = round((float) ($target['mandatory_fees'] ?? 0), 2); + $targetBalances = $this->invoiceBalancesByType($parentId, $targetSchoolYear); + $carryForward = round((float) ($targetBalances['carry_forward'] ?? 0.0), 2); + $sourcePositiveOutstanding = $sourceSchoolYear !== '' + ? $this->positiveOutstandingBalance($parentId, $sourceSchoolYear) + : 0.0; + $hasTargetYearCarryForwardInvoices = $this->parentHasTargetYearCarryForwardInvoices($parentId, $targetSchoolYear); + + if ($sourcePositiveOutstanding <= 0.0) { + // Previous-year invoices are settled (including discounted/paid accounts). + // Ignore stale carry-forward invoices that may still show an opening balance. + $carryForward = 0.0; + } elseif ($carryForward <= 0.0 && $sourceSchoolYear !== '' && ! $hasTargetYearCarryForwardInvoices) { + $carryForward = round($sourcePositiveOutstanding, 2); + } + + $currentYearBalance = round((float) ($targetBalances['current_year'] ?? 0.0), 2); + $totalExistingBalance = round(max(0.0, $carryForward) + max(0.0, $currentYearBalance), 2); + $tuitionDue = round(max(0.0, $tuitionDue), 2); + $behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'); return [ 'currency' => '$', 'source_school_year' => $sourceSchoolYear, 'target_school_year' => $targetSchoolYear, - 'carry_over_balance' => round($carryOver, 2), - 'current_balance' => round($current, 2), - 'registration_fee' => $registrationFee, + 'carry_forward_balance' => $carryForward, + 'carry_over_balance' => $carryForward, + 'current_year_balance' => $currentYearBalance, + 'current_balance' => $currentYearBalance, + 'total_existing_balance' => $totalExistingBalance, 'tuition_due_at_registration' => $tuitionDue, - 'mandatory_fees' => $mandatory, - 'amount_due' => round(max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatory, 2), - 'balance_behavior' => (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'), + 'total_enrollment_due' => round($totalExistingBalance + $tuitionDue, 2), + 'amount_due' => round($totalExistingBalance + $tuitionDue, 2), + 'balance_behavior' => $behavior, + 'payment_plan_available' => (bool) ($target['payment_plan_available'] ?? false), 'evaluated_at' => date('Y-m-d H:i:s'), ]; } + private function applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void + { + $summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); + $evaluation['financial_summary'] = $summary; + + $balance = (float) ($summary['carry_forward_balance'] ?? 0.0); + $behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'); + if ($balance <= 0.0 || ! in_array($behavior, self::BLOCKING_BALANCE_BEHAVIORS, true)) { + return; + } + + if ($behavior === 'admin_approval_required') { + $this->addBlocker($evaluation, 'FINANCE_APPROVAL_REQUIRED', EnrollmentEligibility::FINANCE_APPROVAL_PORTAL_MESSAGE); + + return; + } + + $this->addBlocker($evaluation, 'OUTSTANDING_BALANCE_BLOCKED', EnrollmentEligibility::BALANCE_PORTAL_MESSAGE); + } + + /** + * @return array{carry_forward: float, current_year: float} + */ + private function invoiceBalancesByType(int $parentId, string $schoolYear): array + { + if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) { + return ['carry_forward' => 0.0, 'current_year' => 0.0]; + } + + $rows = $this->db->table('invoices') + ->select('id, invoice_number, semester, description, balance, status') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getResultArray(); + + $carryForward = 0.0; + $currentYear = 0.0; + foreach ($rows as $row) { + $balance = round($this->enrollmentOutstandingBalance($row), 2); + if ($balance <= 0.0) { + continue; + } + + if ($this->isCarryForwardInvoiceRow($row)) { + $carryForward += $balance; + } else { + $currentYear += $balance; + } + } + + return [ + 'carry_forward' => round($carryForward, 2), + 'current_year' => round($currentYear, 2), + ]; + } + + private function parentHasTargetYearCarryForwardInvoices(int $parentId, string $targetSchoolYear): bool + { + if ($parentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('invoices')) { + return false; + } + + $rows = $this->db->table('invoices') + ->select('invoice_number, semester, description') + ->where('parent_id', $parentId) + ->where('school_year', $targetSchoolYear) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + if ($this->isCarryForwardInvoiceRow($row)) { + return true; + } + } + + return false; + } + + /** + * @param array $invoice + */ + private function isCarryForwardInvoiceRow(array $invoice): bool + { + $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); + if (str_starts_with($invoiceNumber, 'CF-')) { + return true; + } + + if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) { + return true; + } + + $description = strtolower((string) ($invoice['description'] ?? '')); + + return str_contains($description, 'carried over') + || str_contains($description, 'carry-forward') + || str_contains($description, 'carry over') + || str_contains($description, 'previous school year'); + } + + private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): array + { + return $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); + } + private function invoiceBalanceForParent(int $parentId, string $schoolYear): float + { + return $this->positiveOutstandingBalance($parentId, $schoolYear); + } + + private function positiveOutstandingBalance(int $parentId, string $schoolYear): float { if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) { return 0.0; } - $row = $this->db->table('invoices') - ->select('COALESCE(SUM(balance), 0) AS balance', false) + $rows = $this->db->table('invoices') + ->select('id, invoice_number, semester, description, balance, status') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->get() - ->getRowArray(); + ->getResultArray(); - return round((float) ($row['balance'] ?? 0), 2); + $total = 0.0; + foreach ($rows as $row) { + $total += round($this->enrollmentOutstandingBalance($row), 2); + } + + return round($total, 2); + } + + /** + * Enrollment should not block on invoices already settled in the billing UI, and + * should not inflate balances when ledger recalculation omits legacy discount rows. + * + * @param array $invoice + */ + private function enrollmentOutstandingBalance(array $invoice): float + { + $storedBalance = round((float) ($invoice['balance'] ?? 0.0), 2); + if ($storedBalance <= 0.0) { + return 0.0; + } + + $normalizedStatus = FinancialStatus::normalizeInvoiceStatus((string) ($invoice['status'] ?? '')); + if (in_array($normalizedStatus, [ + FinancialStatus::INVOICE_PAID, + FinancialStatus::INVOICE_CREDITED, + FinancialStatus::INVOICE_OVERPAID, + FinancialStatus::INVOICE_VOIDED, + FinancialStatus::INVOICE_CANCELLED, + ], true)) { + return 0.0; + } + + $ledgerBalance = max(0.0, round($this->resolvedInvoiceBalance($invoice), 2)); + + return min($storedBalance, $ledgerBalance); + } + + private function isSettledInvoiceStatus(string $status): bool + { + $normalizedStatus = FinancialStatus::normalizeInvoiceStatus($status); + + return in_array($normalizedStatus, [ + FinancialStatus::INVOICE_PAID, + FinancialStatus::INVOICE_CREDITED, + FinancialStatus::INVOICE_OVERPAID, + FinancialStatus::INVOICE_VOIDED, + FinancialStatus::INVOICE_CANCELLED, + ], true); + } + + /** + * @param array $invoice + */ + private function resolvedInvoiceBalance(array $invoice): float + { + $invoiceId = (int) ($invoice['id'] ?? 0); + if ($invoiceId <= 0) { + return round((float) ($invoice['balance'] ?? 0.0), 2); + } + + if (! array_key_exists($invoiceId, $this->resolvedInvoiceBalanceCache)) { + try { + $calculation = $this->invoiceLedger()->calculateInvoice($invoiceId); + $this->resolvedInvoiceBalanceCache[$invoiceId] = round((float) ($calculation['balance'] ?? 0.0), 2); + } catch (\Throwable) { + $this->resolvedInvoiceBalanceCache[$invoiceId] = round((float) ($invoice['balance'] ?? 0.0), 2); + } + } + + return $this->resolvedInvoiceBalanceCache[$invoiceId]; + } + + private function invoiceLedger(): InvoiceLedgerService + { + return $this->invoiceLedgerService ??= new InvoiceLedgerService(); } private function applyScopedException(array &$evaluation, int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear): void @@ -1717,61 +2340,76 @@ final class EnrollmentTransitionService return; } - if (in_array('REGISTRATION_CLOSED', $failedCodes, true)) { - $targetYear = $this->schoolYearByName($targetSchoolYear); - if ((int) ($targetYear['administrative_exceptions_permitted'] ?? 0) !== 1) { - return; - } - } - - $exception = $this->activeException($parentId, $studentId, $targetSchoolYear); + $exception = $this->applicableException($parentId, $studentId, $targetSchoolYear); if ($exception === null) { return; } - $allowedCodes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true); - $allowedCodes = is_array($allowedCodes) ? array_values(array_filter(array_map('strval', $allowedCodes))) : []; - if ($allowedCodes === [] || array_diff($failedCodes, $allowedCodes) !== []) { - return; - } + $overridableFailed = array_values(array_diff($failedCodes, self::NON_OVERRIDABLE_RULE_CODES)); + $recordedCodes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true); + $recordedCodes = is_array($recordedCodes) ? array_values(array_filter(array_map('strval', $recordedCodes))) : []; $evaluation['admin_exception'] = [ 'id' => (int) $exception['id'], 'reason_code' => (string) ($exception['reason_code'] ?? ''), 'source_school_year' => $sourceSchoolYear, - 'bypassed_rule_codes' => $failedCodes, + 'status' => (string) ($exception['status'] ?? ''), + 'bypassed_rule_codes' => $overridableFailed !== [] ? $overridableFailed : $recordedCodes, ]; $evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE; $evaluation['parent_enrollment_allowed'] = true; $evaluation['can_enroll'] = true; } - private function activeException(int $parentId, int $studentId, string $schoolYear): ?array + /** + * Active exceptions authorize enrollment. Used exceptions remain authoritative for the + * same school year so later admin steps (class assignment, status advances) stay allowed. + */ + private function applicableException(int $parentId, int $studentId, string $schoolYear): ?array { if ($parentId <= 0 || $studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) { return null; } $now = date('Y-m-d H:i:s'); - - return $this->db->table('enrollment_exceptions') + $rows = $this->db->table('enrollment_exceptions') ->where('parent_id', $parentId) ->where('student_id', $studentId) ->where('school_year', $schoolYear) - ->where('status', 'active') - ->groupStart() - ->where('starts_at IS NULL', null, false) - ->orWhere('starts_at <=', $now) - ->groupEnd() - ->groupStart() - ->where('expires_at IS NULL', null, false) - ->orWhere('expires_at >=', $now) - ->groupEnd() + ->whereIn('status', ['active', 'used']) ->orderBy('created_at', 'DESC') ->orderBy('id', 'DESC') - ->limit(1) ->get() - ->getRowArray() ?: null; + ->getResultArray(); + + foreach ($rows as $row) { + $status = strtolower(trim((string) ($row['status'] ?? ''))); + if ($status === 'used') { + return $row; + } + + if ($status !== 'active') { + continue; + } + + $startsAt = trim((string) ($row['starts_at'] ?? '')); + $expiresAt = trim((string) ($row['expires_at'] ?? '')); + if ($startsAt !== '' && $startsAt > $now) { + continue; + } + if ($expiresAt !== '' && $expiresAt < $now) { + continue; + } + + return $row; + } + + return null; + } + + private function activeException(int $parentId, int $studentId, string $schoolYear): ?array + { + return $this->applicableException($parentId, $studentId, $schoolYear); } private function finalizeParentDecision(array &$evaluation): void diff --git a/app/Services/EnrollmentWithdrawalService.php b/app/Services/EnrollmentWithdrawalService.php index ed882e7..360faf6 100644 --- a/app/Services/EnrollmentWithdrawalService.php +++ b/app/Services/EnrollmentWithdrawalService.php @@ -214,6 +214,8 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s $refundAmountByParent = []; // parent_id => preview amount for notification context $validStatuses = EnrollmentStatusService::VALID_STATUSES; + $transitionService = service('enrollmentTransition'); + $advanceStatuses = $transitionService->statusesRequiringEnrollmentEligibility(); foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) { if (!in_array($newEnrollmentStatus, $validStatuses, true)) { @@ -246,6 +248,16 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s continue; } + if (in_array($newEnrollmentStatus, $advanceStatuses, true)) { + $advance = $transitionService->evaluateEnrollmentAdvance($parentId, (int) $studentId, $this->schoolYear, 'admin'); + if (! $transitionService->adminMayAdvanceEnrollment($advance)) { + $transitionService->logEnrollmentBlock($advance, 'admin_updateStatuses_create', $parentId, $performedBy); + $errors[] = "Student ID $studentId cannot be advanced to '$newEnrollmentStatus': " + . (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.'); + continue; + } + } + $isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0; $result = $enrollmentStatusService->upsertStatus([ @@ -323,6 +335,16 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s continue; } + if ($oldStatus !== $newEnrollmentStatus && in_array($newEnrollmentStatus, $advanceStatuses, true)) { + $advance = $transitionService->evaluateEnrollmentAdvance((int) $parentId, (int) $studentId, $this->schoolYear, 'admin'); + if (! $transitionService->adminMayAdvanceEnrollment($advance)) { + $transitionService->logEnrollmentBlock($advance, 'admin_updateStatuses', (int) $parentId, $performedBy); + $errors[] = "Student ID $studentId cannot be advanced to '$newEnrollmentStatus': " + . (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.'); + continue; + } + } + $result = $enrollmentStatusService->upsertStatus([ 'id' => (int) $enrollmentRow['id'], 'student_id' => (int) $studentId, diff --git a/app/Services/RegistrationOpeningEmailService.php b/app/Services/RegistrationOpeningEmailService.php index 7c5c819..f7c947a 100644 --- a/app/Services/RegistrationOpeningEmailService.php +++ b/app/Services/RegistrationOpeningEmailService.php @@ -4,6 +4,9 @@ namespace App\Services; use CodeIgniter\Database\BaseConnection; +/** + * @deprecated Use EnrollmentRegistrationEmailService instead. This legacy sender is retained for reference only. + */ class RegistrationOpeningEmailService { public const TEMPLATE_KEY = 'registration_opening'; diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 0c4274d..2eab10d 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -753,8 +753,9 @@ final class SchoolYearClosingService ]; if ($this->db->fieldExists('description', 'invoices')) { - $label = $amount > 0 ? 'Balance carried over' : 'Credit carried over'; - $payload['description'] = "{$label} from previous school year {$sourceYear}."; + $payload['description'] = $amount >= 0 + ? "Carry over balance from last year {$sourceYear}" + : "Credit carry over from last year {$sourceYear}"; } $invoiceModel = new InvoiceModel(); @@ -763,7 +764,17 @@ final class SchoolYearClosingService throw new RuntimeException('Unable to create carry-forward invoice: ' . json_encode($invoiceModel->errors())); } - return (int) $invoiceId; + $invoiceId = (int) $invoiceId; + $description = (string) ($payload['description'] ?? "Carry over balance from last year {$sourceYear}"); + try { + $ledgerService = new \App\Libraries\InvoiceLedgerService(); + $ledgerService->issueCarryForwardInvoiceLine($invoiceId, $amount, $description); + $ledgerService->recalculateInvoice($invoiceId); + } catch (\Throwable $e) { + log_message('error', 'Carry-forward invoice line issuance failed for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + return $invoiceId; } private function carryForwardInvoiceNumber(int $itemId, int $parentId, string $sourceYear, string $targetYear): string diff --git a/app/Support/Enrollment/EnrollmentEligibility.php b/app/Support/Enrollment/EnrollmentEligibility.php index ebd5135..6aa6946 100644 --- a/app/Support/Enrollment/EnrollmentEligibility.php +++ b/app/Support/Enrollment/EnrollmentEligibility.php @@ -8,7 +8,106 @@ final class EnrollmentEligibility public const WITHDRAWN_MESSAGE = 'Re-enrollment is not available because the final deliberation decision is withdrawn. Please contact the school administration if this status needs to be reviewed.'; public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.'; public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'; - public const ADULT_STUDENT_MESSAGE = 'The student will be 18 years old or older on September 1. A parent or guardian cannot complete registration on the student’s behalf. The student must complete the authorized adult-student registration process or contact the school administration.'; + public const ADULT_STUDENT_MESSAGE = 'This student is over the allowed age for enrollment or re-enrollment at the school. Please contact school administration.'; + public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE; + public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.'; + public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.'; + public const BALANCE_PORTAL_MESSAGE = 'Enrollment cannot continue because there is an outstanding balance. Please contact school administration.'; + public const EXPELLED_PORTAL_MESSAGE = 'This student cannot be enrolled through the parent portal. Please contact school administration.'; + public const FINANCE_APPROVAL_PORTAL_MESSAGE = 'Registration requires administrative financial approval. Please contact school administration.'; + public const ALREADY_ENROLLED_MESSAGE = 'This student is already enrolled for the selected school year.'; + public const ALREADY_SUBMITTED_MESSAGE = 'This student already has an enrollment for the selected school year.'; + + public const ADULT_STUDENT_MIN_AGE = 18; + + /** @var array Lower numbers win when selecting the primary parent-facing blocker. */ + public const BLOCKER_PRIORITY = [ + 'ALREADY_ENROLLED' => 0, + 'ADULT_STUDENT_PARENT_BLOCKED' => 1, + 'ADULT_STUDENT_ACTION_REQUIRED' => 1, + 'EXPELLED' => 2, + 'WITHDRAWN' => 3, + 'OUTSTANDING_BALANCE_BLOCKED' => 4, + 'SIBLING_LAST_NAME_MISMATCH' => 5, + 'FINANCE_APPROVAL_REQUIRED' => 6, + 'DEFERRED_DECISION' => 7, + 'NO_FINAL_DECISION' => 8, + 'UNRECOGNIZED_DECISION' => 9, + ]; + + public static function messageForRuleCode(string $ruleCode): string + { + return match ($ruleCode) { + 'ADULT_STUDENT_PARENT_BLOCKED', 'ADULT_STUDENT_ACTION_REQUIRED' => self::ADULT_STUDENT_PARENT_PORTAL_MESSAGE, + 'EXPELLED' => self::EXPELLED_PORTAL_MESSAGE, + 'WITHDRAWN' => self::WITHDRAWN_PORTAL_MESSAGE, + 'OUTSTANDING_BALANCE_BLOCKED' => self::BALANCE_PORTAL_MESSAGE, + 'SIBLING_LAST_NAME_MISMATCH' => self::SIBLING_PORTAL_MESSAGE, + 'FINANCE_APPROVAL_REQUIRED' => self::FINANCE_APPROVAL_PORTAL_MESSAGE, + 'DEFERRED_DECISION' => self::DEFERRED_MESSAGE, + 'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION' => self::MISSING_DECISION_MESSAGE, + 'ALREADY_ENROLLED' => self::ALREADY_ENROLLED_MESSAGE, + default => 'Enrollment cannot continue at this time. Please contact school administration.', + }; + } + + public static function alreadyEnrolledMessage(?string $enrollmentStatus = null): string + { + return match (strtolower(trim((string) $enrollmentStatus))) { + 'payment pending' => 'This student already has an enrollment for the selected school year. Payment is still pending.', + 'admission under review', 'review & decision' => 'This student already has an enrollment submitted for the selected school year and it is under review.', + 'waitlist' => 'This student is already on the waitlist for the selected school year.', + 'withdraw under review' => 'This student is already enrolled and has a withdrawal request under review.', + 'refund pending' => 'This student already has an enrollment for the selected school year. A refund is pending.', + 'enrolled' => self::ALREADY_ENROLLED_MESSAGE, + default => self::ALREADY_SUBMITTED_MESSAGE, + }; + } + + public static function alreadyEnrolledTitle(?string $enrollmentStatus = null): string + { + return match (strtolower(trim((string) $enrollmentStatus))) { + 'payment pending', 'admission under review', 'review & decision' => 'Enrollment Already Submitted', + 'waitlist' => 'Already on Waitlist', + 'withdraw under review' => 'Withdrawal Under Review', + 'refund pending' => 'Refund Pending', + default => 'Already Enrolled', + }; + } + + /** + * @param array $student + */ + public static function isMarkedWithdrawn(array $student): bool + { + if ((int) ($student['is_withdrawn'] ?? 0) === 1) { + return true; + } + + $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); + + return $status === 'withdrawn' || $status === 'withdrawn student'; + } + + /** + * @param list $blockers + */ + public static function selectPrimaryBlocker(array $blockers): ?string + { + $blockers = array_values(array_unique(array_filter(array_map('strval', $blockers)))); + if ($blockers === []) { + return null; + } + + usort($blockers, static function (string $a, string $b): int { + $priorityA = self::BLOCKER_PRIORITY[$a] ?? 999; + $priorityB = self::BLOCKER_PRIORITY[$b] ?? 999; + + return $priorityA <=> $priorityB ?: strcmp($a, $b); + }); + + return $blockers[0]; + } public static function ageOnSeptemberFirst(?string $dob, string $targetSchoolYear): ?int { @@ -50,7 +149,12 @@ final class EnrollmentEligibility return self::message($name . ': ' . self::EXPELLED_MESSAGE, true, 'danger'); } - if ($decision === DeliberationDecision::WITHDRAWN || ($student['enrollment_status'] ?? null) === 'withdrawn') { + $age = self::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); + if ($age !== null && $age >= self::ADULT_STUDENT_MIN_AGE) { + return self::message(str_replace('The student', $name, self::ADULT_STUDENT_MESSAGE), true, 'danger'); + } + + if ($decision === DeliberationDecision::WITHDRAWN || self::isMarkedWithdrawn($student)) { return self::message($name . ': ' . self::WITHDRAWN_MESSAGE, true, 'warning'); } @@ -62,11 +166,6 @@ final class EnrollmentEligibility return self::message($name . ': ' . self::MISSING_DECISION_MESSAGE, true, 'warning'); } - $age = self::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); - if ($age !== null && $age >= 18) { - return self::message(str_replace('The student', $name, self::ADULT_STUDENT_MESSAGE), true, 'danger'); - } - if ($decision === DeliberationDecision::MAKE_UP_EXAM) { $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; return self::message( diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index 7a05cb2..20c8522 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -48,6 +48,18 @@ white-space: normal; text-align: left; } + .enrollment-admin .issue-badge.adult-student, + .enrollment-admin .adult-student-name { + background-color: #b02a37; + color: #fff; + } + .enrollment-admin .adult-student-name { + display: inline-block; + border-radius: .25rem; + font-weight: 600; + padding: .15rem .35rem; + margin: .1rem .15rem .1rem 0; + } endSection() ?> @@ -62,12 +74,11 @@ if (!function_exists('enrollment_admin_flag_label')) { 'AGE_EXCEPTION_REQUIRED' => 'Age exception', 'LATE_REGISTRATION_EXCEPTION' => 'Late registration', 'FINANCIAL_REVIEW_REQUIRED' => 'Finance review', - 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'Class capacity', 'DEFERRED_DELIBERATION' => 'Decision review', 'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'Restricted review', 'WITHDRAWAL_REVIEW_REQUIRED' => 'Withdrawal review', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'Exit / completion', - 'ADULT_STUDENT_ACTION_REQUIRED' => 'Adult student', + 'ADULT_STUDENT_ACTION_REQUIRED', 'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult student', 'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names', 'NO_FINAL_DECISION' => 'No final decision', 'UNRECOGNIZED_DECISION' => 'Unrecognized decision', @@ -110,8 +121,9 @@ if (!function_exists('enrollment_admin_issue_badge_class')) { return match (strtoupper($code)) { 'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'bg-danger', 'DEFERRED_DECISION', 'DEFERRED_DELIBERATION', 'FINANCIAL_REVIEW_REQUIRED', 'OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED' => 'bg-warning text-dark', - 'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED', 'ADULT_STUDENT_ACTION_REQUIRED' => 'bg-info text-dark', - 'CLASS_REASSIGNMENT_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'bg-primary', + 'ADULT_STUDENT_ACTION_REQUIRED', 'ADULT_STUDENT_PARENT_BLOCKED' => 'adult-student', + 'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED' => 'bg-info text-dark', + 'CLASS_REASSIGNMENT_REQUIRED' => 'bg-primary', 'WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'bg-dark', 'SIBLING_LAST_NAME_MISMATCH', 'LATE_REGISTRATION_EXCEPTION' => 'bg-secondary', default => 'bg-secondary', @@ -147,7 +159,7 @@ if (!function_exists('enrollment_admin_rule_label')) { 'AGE_RULE_BLOCKED' => 'Age rule not met', 'REGISTRATION_CLOSED' => 'Registration is closed', 'REGISTRATION_NOT_OPEN' => 'Registration is not open yet', - 'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult student cannot be enrolled by a parent', + 'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult students cannot enroll or re-enroll at the school', 'EXIT_REQUIRED' => 'Exit / completion required', 'EXPELLED' => 'Expelled — administrative review', 'WITHDRAWN' => 'Withdrawn — review required', @@ -405,7 +417,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : - +
Approve an exception for this student
@@ -528,7 +540,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
>
@@ -562,9 +574,8 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : -
- - +
+
Applies for the selected school year ().
@@ -601,7 +612,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : Reason Bypassed rules Created by - Expires + School year Action @@ -633,7 +644,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : - + @@ -751,7 +762,20 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : - + + + $student): ?> + 0 ? ', ' : '' ?> + + + + + + + + + + diff --git a/app/Views/administrator/financial_aid_review.php b/app/Views/administrator/financial_aid_review.php index 793853e..476ed5b 100644 --- a/app/Views/administrator/financial_aid_review.php +++ b/app/Views/administrator/financial_aid_review.php @@ -9,22 +9,51 @@
-
Parent:
-
School year:
-
Status:
-
Household size:
-
Requested amount:
-
Need statement
-

-
Students
-
    - -
  • - - -
  • - -
+
+
+
Parent:
+
Household size:
+
Requested amount:
+
Parent statement
+

+
Students
+
    + +
  • + + +
  • + +
+
+
+
Aid estimate calculator
+
+ + +
+
+ + +
+
+ + +
+ +
+
Financial aid calculation completed.
+
+
Estimated aid
+
$
+
Capped by request and balance
+
$
+
+
+
@@ -53,3 +82,138 @@
endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/Views/inventory/book/form.php b/app/Views/inventory/book/form.php index 80fcaea..0729e81 100644 --- a/app/Views/inventory/book/form.php +++ b/app/Views/inventory/book/form.php @@ -54,12 +54,26 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
+ 0) { + foreach ($categories as $c) { + if ((int)($c['id'] ?? 0) === $selectedCategoryId) { + $selectedCat = $c; + break; + } + } + } + $editGmin = old('grade_min', $selectedCat['grade_min'] ?? ''); + $editGmax = old('grade_max', $selectedCat['grade_max'] ?? ''); + ?> +
+
+ + +
+
+ + +
+
- Grade Range: - + Grade range is saved on the selected category (shared by all books in that category).
@@ -141,20 +166,35 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses)); +(function() { + const select = document.getElementById('editCategorySelect'); + const nameInput = document.getElementById('editCategoryName'); + const descInput = document.getElementById('editCategoryDescription'); + const gminInput = document.getElementById('editCategoryGradeMin'); + const gmaxInput = document.getElementById('editCategoryGradeMax'); + + function fillEditCategory() { + const opt = select?.options[select.selectedIndex]; + const has = !!(select && select.value); + if (nameInput) nameInput.value = has ? (opt.getAttribute('data-name') || '') : ''; + if (descInput) descInput.value = has ? (opt.getAttribute('data-description') || '') : ''; + if (gminInput) gminInput.value = has ? (opt.getAttribute('data-gmin') || '') : ''; + if (gmaxInput) gmaxInput.value = has ? (opt.getAttribute('data-gmax') || '') : ''; + } + + select?.addEventListener('change', fillEditCategory); + document.getElementById('editCategoryModal')?.addEventListener('show.bs.modal', function (e) { + const trigger = e.relatedTarget; + const preselect = trigger && trigger.getAttribute ? trigger.getAttribute('data-category-id') : null; + if (preselect && select) { + select.value = preselect; + } + fillEditCategory(); + }); +})(); + endSection() ?> diff --git a/app/Views/invoice_payment/invoice_management.php b/app/Views/invoice_payment/invoice_management.php index da78ff9..934dc0c 100644 --- a/app/Views/invoice_payment/invoice_management.php +++ b/app/Views/invoice_payment/invoice_management.php @@ -126,13 +126,22 @@ return name; } function renderInvoiceRow(r) { + const isCarryForward = !!r.is_carry_forward; const ts = Date.parse(r.invoice_date || new Date().toISOString()); - const genBtn = ``; - const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; + const genBtn = isCarryForward + ? 'Audit only' + : ``; + const invoiceLabel = isCarryForward + ? `
Carry-over${esc(r.invoice_description || 'Carry over balance')}
` + + (r.invoice_number ? `
${esc(r.invoice_number)}
` : '') + : renderStudents(r.enrolledKids || []); + const pdf = r.invoice_id + ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` + : (isCarryForward ? '' : 'No invoice yet'); return [ renderParentCell(r), - renderStudents(r.enrolledKids || []), + invoiceLabel, genBtn, fmtMoney(r.invoice_amount), renderRefundCell(r), diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php index 7f53f89..10b2dc5 100644 --- a/app/Views/parent/enroll_classes.php +++ b/app/Views/parent/enroll_classes.php @@ -254,17 +254,35 @@ $statusBadge = static function (?string $status): string { default => 'unknown', }; }; +$alreadyEnrolledMessage = static fn (?string $status): string => \App\Support\Enrollment\EnrollmentEligibility::alreadyEnrolledMessage($status); +$alreadyEnrolledTitle = static fn (?string $status): string => \App\Support\Enrollment\EnrollmentEligibility::alreadyEnrolledTitle($status); +$hasSettledEnrollmentStatus = static function (?string $status, ?string $admissionStatus = ''): bool { + if (strtolower(trim((string) $admissionStatus)) === 'accepted') { + return true; + } + + return in_array(strtolower(trim((string) $status)), [ + 'admission under review', + 'review & decision', + 'payment pending', + 'enrolled', + 'waitlist', + 'withdraw under review', + 'refund pending', + ], true); +}; $enrollableCount = 0; $withdrawableCount = 0; foreach (($students ?? []) as $student) { - $eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['blocking' => false]; - if (($student['enrollment_status'] ?? '') === 'not enrolled' && !$deadlinePassed && $isEditable && empty($eligibilityMessage['blocking'])) { + $evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : []; + if (($evaluation['can_enroll'] ?? false) === true && !$deadlinePassed && $isEditable) { $enrollableCount++; } if (($student['enrollment_status'] ?? '') === 'enrolled' && $isEditable) { $withdrawableCount++; } } +$studentCount = count($students ?? []); ?>
@@ -311,15 +329,17 @@ foreach (($students ?? []) as $student) {
Enrollment closed on format('m-d-Y')) ?>.
+ +
No students are currently linked to this account.
-
No students are currently available for new enrollment.
+
No students are currently eligible for enrollment. You may still click Start Enrollment to review the reason.
@@ -332,6 +352,7 @@ foreach (($students ?? []) as $student) { Decision Required Action Status + Eligibility Withdraw @@ -352,8 +373,24 @@ foreach (($students ?? []) as $student) { - + + + ' . esc($alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? ''))) . ''; + } elseif (($evaluation['can_enroll'] ?? false) === true) { + echo 'Eligible'; + } elseif (! empty($evaluation['primary_parent_message'])) { + echo '' . esc($evaluation['primary_parent_message']) . ''; + } elseif (! empty($student['enrollment_eligibility_message']['message'])) { + echo '' . esc($student['enrollment_eligibility_message']['message']) . ''; + } else { + echo 'Not eligible'; + } + ?> +
@@ -464,12 +501,19 @@ foreach (($students ?? []) as $student) { '', 'blocking' => false, 'level' => 'info']; + $evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : []; $studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); $studentName = $studentName !== '' ? $studentName : 'Student'; - $canEnroll = ($student['enrollment_status'] ?? '') === 'not enrolled' + $canEnroll = ($evaluation['can_enroll'] ?? false) === true && !$deadlinePassed - && $isEditable - && empty($eligibilityMessage['blocking']); + && $isEditable; + $hasSettledEnrollment = $hasSettledEnrollmentStatus($student['enrollment_status'] ?? '', $student['admission_status'] ?? ''); + $blockMessage = $hasSettledEnrollment + ? $alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')) + : (string) ($evaluation['primary_parent_message'] ?? $eligibilityMessage['message'] ?? ''); + $blockTitle = $hasSettledEnrollment + ? $alreadyEnrolledTitle((string) ($student['enrollment_status'] ?? '')) + : 'Enrollment Not Available'; $section = $student['class_section'] ?? null; $gradeLabel = $section ? preg_replace_callback('/\byouth\b/i', fn($m) => ucfirst(strtolower($m[0])), (string) $section) @@ -502,7 +546,10 @@ foreach (($students ?? []) as $student) { data-required-action="" data-expected-placement="" data-is-new="" - data-selectable=""> + data-selectable="" + data-already-enrolled="" + data-block-title="" + data-block-message="">
@@ -622,7 +669,11 @@ foreach (($students ?? []) as $student) { value="" maxlength="12" inputmode="numeric" + autocomplete="tel" + title="Enter a valid 10-digit phone number (e.g., 123-456-7890)" required> +
10-digit US phone number (e.g., 123-456-7890).
+
@@ -631,8 +682,12 @@ foreach (($students ?? []) as $student) { id="parent-contact-street" name="parent_contact[address_street]" value="" - maxlength="255" + maxlength="50" + autocomplete="address-line1" + title="2–50 characters. Letters, numbers, spaces, periods, and hyphens only." required> +
2–50 characters. Letters, numbers, spaces, periods, and hyphens only.
+
@@ -641,7 +696,11 @@ foreach (($students ?? []) as $student) { id="parent-contact-apt" name="parent_contact[apt]" value="" - maxlength="15"> + maxlength="15" + autocomplete="address-line2" + title="Optional. Letters, numbers, spaces, periods, and hyphens only."> +
Optional. Max 15 characters.
+
@@ -650,8 +709,12 @@ foreach (($students ?? []) as $student) { id="parent-contact-city" name="parent_contact[city]" value="" - maxlength="100" + maxlength="30" + autocomplete="address-level2" + title="2–30 characters. Letters, spaces, periods, apostrophes, and hyphens only." required> +
2–30 characters. Letters and spaces allowed.
+
@@ -662,7 +725,12 @@ foreach (($students ?? []) as $student) { value="" maxlength="5" inputmode="numeric" + pattern="\d{5}" + autocomplete="postal-code" + title="Please enter a 5-digit ZIP code" required> +
5-digit US ZIP code only.
+
@@ -672,6 +740,7 @@ foreach (($students ?? []) as $student) { +
@@ -777,6 +846,22 @@ foreach (($students ?? []) as $student) { +