db->table($this->schoolYearModel->getTable())->countAllResults(); $nextDraft = $this->nextDraftDefaults(); $requestedName = trim((string) ($payload['name'] ?? '')); if ($existingYears === 0) { if (! preg_match('/^\d{4}-\d{4}$/', $requestedName)) { throw new InvalidArgumentException('Enter the first school year as YYYY-YYYY, for example 2025-2026.'); } $payload['name'] = $requestedName; } else { if ($nextDraft['name'] === null) { throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.'); } $payload['name'] = $nextDraft['name']; } $payload = $this->withCalendarDefaults($payload, (string) $payload['name']); $payload = $this->metadataPayload($payload); $payload['previous_school_year_id'] = $existingYears === 0 ? null : ((int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name'])); $payload['status'] = SchoolYearStatus::DRAFT; $payload['carry_over_balance_behavior'] = 'submission_blocked_until_payment'; $payload['created_by'] = $userId; $payload['updated_by'] = $userId; $this->validationService->validateMetadata($payload); $this->db->transStart(); $id = $this->schoolYearModel->insert($payload, true); if ($id !== false) { if ($existingYears === 0 || $this->schoolYearModel->active() === null) { $this->syncConfigurationFromSchoolYear($payload); } $this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId); } $this->db->transComplete(); if ($id === false || $this->db->transStatus() === false) { throw new RuntimeException($this->firstModelError('Unable to create school year.')); } return (int) $id; } public function ensureNextDraftForClosing(int $sourceYearId, ?int $userId = null): array { $source = $this->requireYear($sourceYearId); $sourceName = (string) ($source['name'] ?? ''); $nextName = $this->nextSchoolYearName($sourceName); if ($nextName === null) { throw new InvalidArgumentException('Unable to determine the next school year name.'); } $existing = $this->schoolYearModel ->where('name', $nextName) ->first(); if ($existing !== null) { return $existing; } $payload = $this->metadataPayload($this->withCalendarDefaults([ 'name' => $nextName, 'previous_school_year_id' => $sourceYearId, ], $nextName)); $payload['status'] = SchoolYearStatus::DRAFT; $payload['carry_over_balance_behavior'] = 'submission_blocked_until_payment'; $payload['created_by'] = $userId; $payload['updated_by'] = $userId; $this->validationService->validateMetadata($payload); $this->db->transStart(); $id = $this->schoolYearModel->insert($payload, true); if ($id !== false) { $this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create_for_closing', $userId, [ 'source_school_year_id' => $sourceYearId, ]); } $this->db->transComplete(); if ($id === false || $this->db->transStatus() === false) { throw new RuntimeException($this->firstModelError('Unable to create next school year.')); } return $this->requireYear((int) $id); } public function nextDraftDefaults(): array { $previousYear = $this->sourceYearForNextDraft(); $name = $previousYear !== null ? $this->nextSchoolYearName((string) ($previousYear['name'] ?? '')) : null; return [ 'name' => $name, 'previous_year' => $previousYear, ]; } public function updateMetadata(int $id, array $payload, ?int $userId = null): void { $year = $this->requireYear($id); $status = (string) $year['status']; if (SchoolYearStatus::isReadonly($status) || $status === SchoolYearStatus::CLOSING) { throw new InvalidArgumentException('This school year is read-only and cannot be edited.'); } $payload = $this->metadataPayload($payload); $payload['updated_by'] = $userId; $this->validationService->validateMetadata($payload, $id); $this->db->transStart(); $updated = $this->schoolYearModel->update($id, $payload); if ($updated !== false) { if ($status === SchoolYearStatus::ACTIVE) { $this->syncConfigurationFromSchoolYear(array_merge($year, $payload)); } $this->log($id, $status, $status, 'metadata_update', $userId); } $this->db->transComplete(); if ($updated === false || $this->db->transStatus() === false) { throw new RuntimeException($this->firstModelError('Unable to update school year.')); } } public function activate(int $id, ?int $userId = null): void { $year = $this->requireYear($id); $from = (string) $year['status']; if (! SchoolYearStatus::canTransition($from, SchoolYearStatus::ACTIVE)) { throw new InvalidArgumentException('Only draft or approved reopened school years can be activated.'); } if ($this->configurationTotalInstructionalWeeks() <= 0) { throw new InvalidArgumentException('Set total instructional weeks before activating this school year.'); } if ((int) ($year['annual_fee_includes_books'] ?? 1) !== 1) { throw new InvalidArgumentException('The withdrawal refund policy requires annual tuition to include books.'); } $this->db->transStart(); $activeYears = $this->schoolYearModel->where('status', SchoolYearStatus::ACTIVE)->findAll(); $now = date('Y-m-d H:i:s'); foreach ($activeYears as $activeYear) { if ((int) $activeYear['id'] === $id) { continue; } $this->schoolYearModel->update((int) $activeYear['id'], [ 'status' => SchoolYearStatus::CLOSING, 'closing_started_at' => $now, 'updated_by' => $userId, ]); $this->log((int) $activeYear['id'], SchoolYearStatus::ACTIVE, SchoolYearStatus::CLOSING, 'activation_displaced_active_year', $userId, [ 'activated_school_year_id' => $id, ]); } $year['carry_over_balance_behavior'] = trim((string) ($year['carry_over_balance_behavior'] ?? '')) ?: 'submission_blocked_until_payment'; $this->schoolYearModel->update($id, [ 'status' => SchoolYearStatus::ACTIVE, 'activated_at' => $now, 'updated_by' => $userId, 'carry_over_balance_behavior' => $year['carry_over_balance_behavior'], ]); $previousYearId = (int) ($year['previous_school_year_id'] ?? 0); if ($previousYearId > 0) { $previousYear = $this->schoolYearModel->find($previousYearId); service('studentYearStatus')->markReturningStudentsForTargetYear( (string) ($previousYear['name'] ?? ''), (string) ($year['name'] ?? '') ); } $this->syncConfigurationFromSchoolYear($year); $this->syncActiveYearSession((string) $year['name']); $this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to activate school year.'); } } public function syncConfigurationForYear(int $id): void { $this->syncConfigurationFromSchoolYear($this->requireYear($id)); } public function deleteDraft(int $id, ?int $userId = null): void { $year = $this->requireYear($id); if (($year['status'] ?? '') !== SchoolYearStatus::DRAFT) { throw new InvalidArgumentException('Only unused draft school years can be deleted. Archive historical years instead.'); } if ($this->hasDependentRecords($id, (string) $year['name'])) { throw new InvalidArgumentException('This school year cannot be deleted because related data exists. Archive historical years instead.'); } $this->db->transStart(); $this->log($id, SchoolYearStatus::DRAFT, null, 'delete_draft', $userId); $this->schoolYearModel->delete($id); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to delete draft school year.'); } } public function archive(int $id, ?int $userId = null): void { $this->transition($id, SchoolYearStatus::ARCHIVED, 'archive', $userId, [ 'archived_at' => date('Y-m-d H:i:s'), ]); } public function reopen(int $id, string $reason, ?int $userId = null): void { if (trim($reason) === '') { throw new InvalidArgumentException('A reopen reason is required.'); } $this->transition($id, SchoolYearStatus::ACTIVE, 'reopen', $userId, [ 'metadata' => ['reason' => trim($reason)], ]); } public function latestTransitionByYear(): array { if (! $this->db->tableExists('school_year_transition_logs')) { return []; } $rows = $this->transitionLogModel ->orderBy('created_at', 'DESC') ->findAll(); $latest = []; foreach ($rows as $row) { $yearId = (int) ($row['school_year_id'] ?? 0); if ($yearId > 0 && ! isset($latest[$yearId])) { $latest[$yearId] = $row; } } return $latest; } public function log(int $schoolYearId, ?string $from, ?string $to, string $action, ?int $userId = null, array $metadata = []): void { if (! $this->db->tableExists('school_year_transition_logs')) { throw new RuntimeException('School year lifecycle tables are missing. Run database migrations before changing school-year status.'); } $this->transitionLogModel->insert([ 'school_year_id' => $schoolYearId, 'from_status' => $from, 'to_status' => $to, 'action' => $action, 'performed_by' => $userId, 'metadata_json' => $metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_SLASHES) : null, 'created_at' => date('Y-m-d H:i:s'), ]); } private function transition(int $id, string $to, string $action, ?int $userId, array $options = []): void { $year = $this->requireYear($id); $from = (string) $year['status']; if (! SchoolYearStatus::canTransition($from, $to)) { throw new InvalidArgumentException("Cannot transition school year from {$from} to {$to}."); } if ($to === SchoolYearStatus::ARCHIVED && ! $this->hasFinalizedClosingBatch($id)) { throw new InvalidArgumentException('A school year can be archived only after a finalized closing batch exists.'); } if ($to === SchoolYearStatus::ACTIVE) { $otherActive = $this->schoolYearModel ->where('status', SchoolYearStatus::ACTIVE) ->where('id !=', $id) ->first(); if ($otherActive !== null) { throw new InvalidArgumentException('Another school year is already active. Activate or close years through the controlled lifecycle first.'); } } $this->db->transStart(); $data = [ 'status' => $to, 'updated_by' => $userId, ]; foreach (['archived_at', 'closed_at', 'closing_started_at'] as $field) { if (isset($options[$field])) { $data[$field] = $options[$field]; } } $this->schoolYearModel->update($id, $data); $this->log($id, $from, $to, $action, $userId, $options['metadata'] ?? []); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to update school year status.'); } } private function requireYear(int $id): array { $year = $this->schoolYearModel->find($id); if ($year === null) { throw new InvalidArgumentException('School year not found.'); } return $year; } private function metadataPayload(array $payload): array { return [ 'name' => trim((string) ($payload['name'] ?? '')), 'starts_on' => $this->nullableDate($payload['starts_on'] ?? null), 'ends_on' => $this->nullableDate($payload['ends_on'] ?? null), 'description' => trim((string) ($payload['description'] ?? '')) ?: null, 'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null), 'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null), 'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null), 'total_instructional_weeks' => $this->nullableInt($payload['total_instructional_weeks'] ?? null), 'annual_fee_includes_books' => 1, 'withdrawal_policy_version' => trim((string) ($payload['withdrawal_policy_version'] ?? 'studied_weeks_v1')) ?: 'studied_weeks_v1', 'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null), ]; } private function syncConfigurationFromSchoolYear(array $schoolYear): void { $name = trim((string) ($schoolYear['name'] ?? '')); if ($name === '') { throw new RuntimeException('Unable to update school-year configuration: school year name is missing.'); } $calendar = $this->calendarPayloadForSchoolYear($name); $schoolYear = $this->withCalendarDefaults($schoolYear, $name); $ageReferenceDate = $this->ageReferenceDateForSchoolYear($name); $yearStart = (string) ($schoolYear['starts_on'] ?? $calendar['starts_on']); $yearEnd = (string) ($schoolYear['ends_on'] ?? $calendar['ends_on']); $registrationDay = (string) ($schoolYear['registration_starts_on'] ?? $calendar['registration_starts_on']); $enrollmentDeadline = (string) ($schoolYear['registration_ends_on'] ?? $calendar['registration_ends_on']); $firstDay = $calendar['first_day_of_school']; $lastDay = $calendar['last_day_of_school']; $finalExam = $calendar['final_exam_day']; $makeupExam = (string) ($schoolYear['fall_makeup_exam_on'] ?? $calendar['fall_makeup_exam_on']); $orientation = $calendar['orientation_day']; $midterm = $calendar['midterm_exam_day']; $installment = $calendar['installment_date']; $springStart = $calendar['spring_semester_start']; $configValues = [ 'school_year' => $name, 'total_instructional_weeks' => (string) ($schoolYear['total_instructional_weeks'] ?? ''), 'date_age_reference' => $ageReferenceDate, 'refund_deadline' => $ageReferenceDate, 'school_year_start_date' => $yearStart, 'school_year_end_date' => $yearEnd, 'registration_starts_on' => $registrationDay, 'enrollment_deadline' => $enrollmentDeadline, 'first_day_of_school' => $firstDay, 'Installment_date' => $installment, 'installment_date' => $installment, 'fall_semester_start' => $yearStart, 'school_start_date' => $firstDay, 'Due_date' => $firstDay, 'due_date' => $firstDay, 'last_day_of_school' => $lastDay, 'Final_Exam_day' => $finalExam, 'final_exam_day' => $finalExam, 'make_up_exam' => $makeupExam, 'makeup_exam_day' => $makeupExam, 'Orientation_day' => $orientation, 'orientation_day' => $orientation, 'Midterm_exam_day' => $midterm, 'midterm_exam_day' => $midterm, 'spring_semester_start' => $springStart, ]; foreach ($configValues as $key => $value) { if (! $this->configurationModel->setConfigValueByKey($key, $value)) { throw new RuntimeException("Unable to update configuration value for {$key}."); } } } private function configurationTotalInstructionalWeeks(): int { $weeks = filter_var($this->configurationModel->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT); return $weeks !== false && $weeks > 0 ? (int) $weeks : 0; } private function withCalendarDefaults(array $payload, string $schoolYearName): array { $calendar = $this->calendarPayloadForSchoolYear($schoolYearName); foreach ($calendar as $key => $value) { if (! array_key_exists($key, $payload) || trim((string) $payload[$key]) === '') { $payload[$key] = $value; } } return $payload; } private function calendarPayloadForSchoolYear(string $schoolYearName): array { if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYearName, $matches)) { throw new RuntimeException('Unable to calculate school-year dates: invalid school year format.'); } $startYear = (int) $matches[1]; $endYear = (int) $matches[2]; $firstDay = $this->nextToLastSundayOfMonth($startYear, 9); $finalExam = $this->lastMondayOfMonth($endYear, 5)->modify('-8 days'); $midterm = $this->nthSundayOfMonth($endYear, 1, 3); return [ 'starts_on' => sprintf('%04d-08-01', $startYear), 'ends_on' => sprintf('%04d-07-31', $endYear), 'registration_starts_on' => sprintf('%04d-08-01', $startYear), 'registration_ends_on' => $firstDay->modify('+15 days')->format('Y-m-d'), 'fall_makeup_exam_on' => $firstDay->modify('-1 week')->format('Y-m-d'), 'orientation_day' => $firstDay->modify('-2 weeks')->format('Y-m-d'), 'first_day_of_school' => $firstDay->format('Y-m-d'), 'installment_date' => sprintf('%04d-03-01', $endYear), 'last_day_of_school' => $finalExam->modify('+2 weeks')->format('Y-m-d'), 'final_exam_day' => $finalExam->format('Y-m-d'), 'midterm_exam_day' => $midterm->format('Y-m-d'), 'spring_semester_start' => $midterm->modify('+1 week')->format('Y-m-d'), ]; } private function nextToLastSundayOfMonth(int $year, int $month): DateTimeImmutable { $lastDay = (new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month))) ->modify('last day of this month'); $lastSunday = $lastDay->modify('-' . ((int) $lastDay->format('w')) . ' days'); return $lastSunday->modify('-1 week'); } private function nthSundayOfMonth(int $year, int $month, int $nth): DateTimeImmutable { $firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)); $daysUntilSunday = (7 - (int) $firstDay->format('w')) % 7; return $firstDay ->modify('+' . $daysUntilSunday . ' days') ->modify('+' . max(0, $nth - 1) . ' weeks'); } private function lastMondayOfMonth(int $year, int $month): DateTimeImmutable { $lastDay = (new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month))) ->modify('last day of this month'); $daysSinceMonday = ((int) $lastDay->format('N') + 6) % 7; return $lastDay->modify('-' . $daysSinceMonday . ' days'); } private function ageReferenceDateForSchoolYear(string $schoolYearName): string { if (! preg_match('/^(\d{4})-\d{4}$/', $schoolYearName, $matches)) { throw new RuntimeException('Unable to update school-year configuration: invalid school year format.'); } return $matches[1] . '-12-31'; } private function previousYearIdForDraft(string $name): ?int { if (preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) { $previousName = ((int) $matches[1] - 1) . '-' . (int) $matches[1]; $previousYear = $this->schoolYearModel ->where('name', $previousName) ->first(); if ($previousYear !== null) { return (int) ($previousYear['id'] ?? 0) ?: null; } } $activeYear = $this->schoolYearModel->active(); if ($activeYear !== null) { return (int) ($activeYear['id'] ?? 0) ?: null; } $latestYear = $this->schoolYearModel ->orderBy('name', 'DESC') ->first(); return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null; } private function sourceYearForNextDraft(): ?array { $activeYear = $this->schoolYearModel->active(); if ($activeYear !== null) { return $activeYear; } return $this->schoolYearModel ->orderBy('name', 'DESC') ->first(); } private function nextSchoolYearName(string $name): ?string { if (! preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) { return null; } $start = (int) $matches[2]; return $start . '-' . ($start + 1); } private function nullableDate(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } private function nullableInt(mixed $value): ?int { return is_numeric($value) && (int) $value > 0 ? (int) $value : null; } private function hasDependentRecords(int $id, string $name): bool { if ( $this->db->tableExists('school_year_closing_batches') && $this->closingBatchModel->where('source_school_year_id', $id)->orWhere('target_school_year_id', $id)->first() !== null ) { return true; } foreach (['invoices', 'payments', 'student_class', 'teacher_class', 'calendar_events', 'events'] as $table) { if ($this->db->tableExists($table) && $this->db->fieldExists('school_year', $table)) { $count = $this->db->table($table)->where('school_year', $name)->countAllResults(); if ($count > 0) { return true; } } } return false; } private function hasFinalizedClosingBatch(int $sourceSchoolYearId): bool { if (! $this->db->tableExists('school_year_closing_batches')) { return false; } return $this->closingBatchModel ->where('source_school_year_id', $sourceSchoolYearId) ->whereIn('status', ['completed', 'closed']) ->first() !== null; } private function firstModelError(string $fallback): string { $errors = $this->schoolYearModel->errors(); $first = reset($errors); return is_string($first) && $first !== '' ? $first : $fallback; } private function syncActiveYearSession(string $schoolYearName): void { if (session_status() !== PHP_SESSION_ACTIVE) { return; } session()->set('school_year', $schoolYearName); session()->remove('selected_school_year_id'); session()->remove('selected_school_year'); } }