diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f473a2f..585e2cb 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Hosting Server +name: Deploy to Shared Hosting on: workflow_dispatch: @@ -10,15 +10,19 @@ on: jobs: deploy: - name: Manual deploy + name: Shared hosting deploy runs-on: ubuntu-latest env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_APP_PATH: ${{ secrets.DEPLOY_APP_PATH }} + DEPLOY_PUBLIC_PATH: ${{ secrets.DEPLOY_PUBLIC_PATH }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_PHP_BIN: ${{ secrets.DEPLOY_PHP_BIN }} + DEPLOY_INDEX_REQUIRE: ${{ secrets.DEPLOY_INDEX_REQUIRE }} GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }} GITEA_PACKAGE_OWNER: ${{ secrets.GITEA_PACKAGE_OWNER }} GITEA_PACKAGE_USER: ${{ secrets.GITEAPACKAGEUSER }} @@ -45,10 +49,8 @@ jobs: run: bash scripts/build-deploy-artifact.sh - name: Publish deployment package + if: ${{ env.GITEA_PACKAGE_USER != '' && env.GITEA_PACKAGE_TOKEN != '' }} run: | - test -n "${GITEA_PACKAGE_USER}" - test -n "${GITEA_PACKAGE_TOKEN}" - BASE_URL="${GITEA_BASE_URL:-https://192.168.3.80}" PACKAGE_OWNER="${GITEA_PACKAGE_OWNER:-melabidi}" PACKAGE_NAME="alrahma_sunday_school" @@ -59,9 +61,7 @@ jobs: PACKAGE_URL="${BASE_URL}/api/packages/${PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${VERSION}/${FILE_NAME}" tar -czf "build/${FILE_NAME}" -C build/deploy . - echo "Publishing package ${PACKAGE_NAME} ${VERSION} to ${BASE_URL}/-/packages" - echo "Package owner: ${PACKAGE_OWNER}" - echo "Package file: ${FILE_NAME}" + echo "Publishing package ${PACKAGE_NAME} ${VERSION}" curl --fail-with-body --insecure \ --user "${GITEA_PACKAGE_USER}:${GITEA_PACKAGE_TOKEN}" \ @@ -70,11 +70,33 @@ jobs: echo "Package uploaded: ${PACKAGE_URL}" + - name: Resolve shared-hosting paths + run: | + APP_PATH="${DEPLOY_APP_PATH:-${DEPLOY_PATH}}" + PUBLIC_PATH="${DEPLOY_PUBLIC_PATH}" + + if [ -z "${APP_PATH}" ]; then + echo "Set DEPLOY_APP_PATH (application directory next to public_html)." + echo "Example: /home/u280815660/domains/home.alrahmaisgl.org/alrahma" + exit 1 + fi + + if [ -z "${PUBLIC_PATH}" ]; then + echo "Set DEPLOY_PUBLIC_PATH (shared-hosting document root)." + echo "Example: /home/u280815660/domains/home.alrahmaisgl.org/public_html" + exit 1 + fi + + echo "APP_PATH=${APP_PATH}" >> "$GITHUB_ENV" + echo "PUBLIC_PATH=${PUBLIC_PATH}" >> "$GITHUB_ENV" + echo "SSH_PORT=${DEPLOY_PORT:-65002}" >> "$GITHUB_ENV" + echo "PHP_BIN=${DEPLOY_PHP_BIN:-/opt/alt/php85/usr/bin/php}" >> "$GITHUB_ENV" + echo "INDEX_REQUIRE=${DEPLOY_INDEX_REQUIRE:-../alrahma/app/Config/Paths.php}" >> "$GITHUB_ENV" + - name: Configure SSH run: | test -n "${DEPLOY_HOST}" test -n "${DEPLOY_USER}" - test -n "${DEPLOY_PATH}" test -n "${DEPLOY_SSH_KEY}" mkdir -p ~/.ssh @@ -82,29 +104,81 @@ jobs: printf '%s\n' "${DEPLOY_SSH_KEY}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key - SSH_PORT="${DEPLOY_PORT:-22}" - ssh-keyscan -p "${SSH_PORT}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts + { + echo "Host shared-hosting" + echo " HostName ${DEPLOY_HOST}" + echo " User ${DEPLOY_USER}" + echo " Port ${SSH_PORT}" + echo " IdentityFile ~/.ssh/deploy_key" + echo " IdentitiesOnly yes" + echo " StrictHostKeyChecking accept-new" + echo " ServerAliveInterval 30" + } > ~/.ssh/config + chmod 600 ~/.ssh/config - - name: Deploy files + ssh-keyscan -p "${SSH_PORT}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts || true + + - name: Prepare remote directories run: | - SSH_PORT="${DEPLOY_PORT:-22}" - - ssh -i ~/.ssh/deploy_key -p "${SSH_PORT}" "${DEPLOY_USER}@${DEPLOY_HOST}" \ - "mkdir -p '${DEPLOY_PATH}' '${DEPLOY_PATH}/writable/cache' '${DEPLOY_PATH}/writable/logs' '${DEPLOY_PATH}/writable/session' '${DEPLOY_PATH}/writable/uploads' '${DEPLOY_PATH}/writable/debugbar'" + ssh shared-hosting "mkdir -p \ + '${APP_PATH}' \ + '${APP_PATH}/writable/cache' \ + '${APP_PATH}/writable/logs' \ + '${APP_PATH}/writable/session' \ + '${APP_PATH}/writable/uploads' \ + '${APP_PATH}/writable/debugbar' \ + '${APP_PATH}/writable/reports' \ + '${PUBLIC_PATH}'" + - name: Deploy application files + run: | rsync -az --delete \ - -e "ssh -i ~/.ssh/deploy_key -p ${SSH_PORT}" \ + -e ssh \ --exclude '.env' \ --exclude 'writable/cache/***' \ --exclude 'writable/logs/***' \ --exclude 'writable/session/***' \ --exclude 'writable/uploads/***' \ --exclude 'writable/debugbar/***' \ - build/deploy/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/" + --exclude 'writable/reports/***' \ + build/deploy/ "shared-hosting:${APP_PATH}/" - - name: Finalize deployment + - name: Deploy public_html document root run: | - SSH_PORT="${DEPLOY_PORT:-22}" + rsync -az --delete \ + -e ssh \ + --exclude 'uploads/***' \ + --exclude 'cgi-bin/***' \ + --exclude '.well-known/***' \ + --exclude 'error_log' \ + --exclude 'default.php' \ + build/deploy/public/ "shared-hosting:${PUBLIC_PATH}/" - ssh -i ~/.ssh/deploy_key -p "${SSH_PORT}" "${DEPLOY_USER}@${DEPLOY_HOST}" \ - "cd '${DEPLOY_PATH}' && php spark cache:clear || true" + - name: Finalize shared-hosting release + run: | + ssh shared-hosting "APP_PATH=$(printf '%q' "${APP_PATH}") PUBLIC_PATH=$(printf '%q' "${PUBLIC_PATH}") INDEX_REQUIRE=$(printf '%q' "${INDEX_REQUIRE}") PHP_BIN=$(printf '%q' "${PHP_BIN}") bash -s" <<'REMOTE' + set -euo pipefail + + test -f "${PUBLIC_PATH}/index.php" + test -d "${APP_PATH}/app" + test -d "${APP_PATH}/vendor" + + grep -q "require FCPATH . '" "${PUBLIC_PATH}/index.php" + sed -i "s|require FCPATH . '../[^']*app/Config/Paths.php';|require FCPATH . '${INDEX_REQUIRE}';|" "${PUBLIC_PATH}/index.php" + grep -F "require FCPATH . '${INDEX_REQUIRE}';" "${PUBLIC_PATH}/index.php" + + chmod 644 "${PUBLIC_PATH}/index.php" "${PUBLIC_PATH}/.htaccess" || true + find "${PUBLIC_PATH}" -type d -exec chmod 755 {} + + find "${APP_PATH}/writable" -type d -exec chmod 775 {} + || true + find "${APP_PATH}/writable" -type f -exec chmod 664 {} + || true + + if [ -x "${PHP_BIN}" ]; then + (cd "${APP_PATH}" && "${PHP_BIN}" spark cache:clear) || true + else + (cd "${APP_PATH}" && php spark cache:clear) || true + fi + + echo "Deployed app to ${APP_PATH}" + echo "Deployed document root to ${PUBLIC_PATH}" + echo "index.php requires ${INDEX_REQUIRE}" + REMOTE diff --git a/.gitignore b/.gitignore index 4a359db..32121bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ .env .env.* !.env.example +/env +/env.production +/env.backup +/env_for_test* +/environment.txt +/env\ (copy\ 1) /vendor/ /node_modules/ diff --git a/DEPLOYMENT_FILES.md b/DEPLOYMENT_FILES.md index fe3f5f8..408b6fd 100644 --- a/DEPLOYMENT_FILES.md +++ b/DEPLOYMENT_FILES.md @@ -31,24 +31,57 @@ Excluded: - local scripts that are not needed at runtime - runtime cache, logs, sessions, uploads, and generated reports -The server keeps its own `.env` and runtime `writable/` contents. The deploy job syncs from `build/deploy/` and excludes mutable writable subdirectories so production data is not overwritten. +The server keeps its own `.env` and runtime `writable/` contents. The deploy job never overwrites those. + +## Shared hosting layout + +`.gitea/workflows/deploy.yml` deploys to Hostinger-style shared hosting, where the document root is `public_html` and the CodeIgniter application lives in a sibling directory: + +```text +/home/USER/domains/home.alrahmaisgl.org/alrahma application (not web-accessible) +/home/USER/domains/home.alrahmaisgl.org/public_html document root (contents of public/) +``` + +`public_html/index.php` is patched to load: + +```php +require FCPATH . '../alrahma/app/Config/Paths.php'; +``` + +Existing `public_html/uploads`, `cgi-bin`, and `.well-known` are left in place. + +## Gitea repository secrets + +Required for SSH deploy: + +- `DEPLOY_HOST` - SSH hostname from the Hostinger panel +- `DEPLOY_USER` - SSH username (for example `u280815660`) +- `DEPLOY_SSH_KEY` - private key whose public half is in Hostinger SSH Access +- `DEPLOY_APP_PATH` - application directory, for example `/home/u280815660/domains/home.alrahmaisgl.org/alrahma` +- `DEPLOY_PUBLIC_PATH` - document root, for example `/home/u280815660/domains/home.alrahmaisgl.org/public_html` + +Optional: + +- `DEPLOY_PORT` - defaults to `65002` (Hostinger SSH). Use `22` only if the host uses the standard port. +- `DEPLOY_PHP_BIN` - defaults to `/opt/alt/php85/usr/bin/php` +- `DEPLOY_INDEX_REQUIRE` - defaults to `../alrahma/app/Config/Paths.php` +- `DEPLOY_PATH` - fallback for `DEPLOY_APP_PATH` if the newer secret is not set + +Create `.env` once on the server under `DEPLOY_APP_PATH`. Do not put database or mail passwords in the workflow. ## Gitea Package -The deploy workflow also archives `build/deploy/` and publishes it to Gitea Packages as a generic package: +The deploy workflow also archives `build/deploy/` and publishes it to Gitea Packages when package credentials are present: - Package name: `alrahma_sunday_school` - Version format: `{branch}-{run_number}-{short_sha}` - File format: `alrahma_sunday_school-{version}.tar.gz` -Required repository secrets for package publishing: +Optional package secrets: - `GITEAPACKAGEUSER` - `GITEAPACKAGETOKEN` - -Optional repository secrets: - -- `GITEA_BASE_URL` - defaults to `https://192.168.3.80`. -- `GITEA_PACKAGE_OWNER` - defaults to `melabidi`. +- `GITEA_BASE_URL` - defaults to `https://192.168.3.80` +- `GITEA_PACKAGE_OWNER` - defaults to `melabidi` The token must have permission to publish packages for the package owner. diff --git a/app/Commands/RefreshEnrollmentAdminTables.php b/app/Commands/RefreshEnrollmentAdminTables.php new file mode 100644 index 0000000..3086718 --- /dev/null +++ b/app/Commands/RefreshEnrollmentAdminTables.php @@ -0,0 +1,217 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--limit' => 'Maximum number of source-year students to process.', + '--dry-run' => 'Preview target/source years and source student count without writing.', + ]; + + private BaseConnection $db; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $options = $this->parseOptions($params); + $schoolYear = trim((string) ($options['school-year'] ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $sourceYear = $this->previousSchoolYearName($schoolYear); + if ($schoolYear === '' || $sourceYear === null) { + CLI::error('Unable to determine target/source school year.'); + return; + } + + $limit = (int) ($options['limit'] ?? 0); + $dryRun = ! empty($options['dry-run']); + $students = $this->sourceStudents($sourceYear, $limit); + + CLI::write('Enrollment admin refresh', 'cyan'); + CLI::write('Target year: ' . $schoolYear); + CLI::write('Source year: ' . $sourceYear); + CLI::write('Source students: ' . count($students)); + + $before = $this->dashboardCounts($schoolYear); + $this->printCounts('Before', $before); + + if ($dryRun) { + CLI::write('Dry-run complete. Re-run without --dry-run to write updates.', 'yellow'); + return; + } + + $processed = 0; + $eligible = 0; + $blocked = 0; + $errors = 0; + $transitionService = service('enrollmentTransition'); + + foreach ($students as $student) { + $studentId = (int) ($student['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + try { + $evaluation = $transitionService->applyInitialTransition( + $studentId, + $sourceYear, + $schoolYear, + is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null, + null, + 'admin' + ); + + $processed++; + if (! empty($evaluation['academic_eligible']) && ($evaluation['blockers'] ?? []) === []) { + $eligible++; + } else { + $blocked++; + } + } catch (Throwable $e) { + $errors++; + CLI::error('Student #' . $studentId . ' failed: ' . $e->getMessage()); + } + } + + $after = $this->dashboardCounts($schoolYear); + $this->printCounts('After', $after); + CLI::write('Processed: ' . $processed, 'white'); + CLI::write('Eligible/applied: ' . $eligible, 'green'); + CLI::write('Blocked/flagged: ' . $blocked, $blocked > 0 ? 'yellow' : 'white'); + CLI::write('Errors: ' . $errors, $errors > 0 ? 'red' : 'white'); + } + + private function parseOptions(array $params): array + { + $options = [ + 'school-year' => '', + 'limit' => 0, + 'dry-run' => false, + ]; + + $rawParams = array_merge($params, array_slice($_SERVER['argv'] ?? [], 2)); + + foreach ($rawParams as $param) { + $value = trim((string) $param); + if ($value === '--dry-run') { + $options['dry-run'] = true; + continue; + } + + if (! str_starts_with($value, '--') || ! str_contains($value, '=')) { + continue; + } + + [$key, $raw] = explode('=', substr($value, 2), 2); + if (array_key_exists($key, $options)) { + $options[$key] = $raw; + } + } + + return $options; + } + + private function sourceStudents(string $sourceYear, int $limit): array + { + if (! $this->db->tableExists('student_class')) { + return []; + } + + $builder = $this->db->table('student_class sc') + ->select('sc.student_id, s.parent_id') + ->join('students s', 's.id = sc.student_id', 'left') + ->where('sc.school_year', $sourceYear) + ->groupBy('sc.student_id, s.parent_id') + ->orderBy('sc.student_id', 'ASC'); + + if ($limit > 0) { + $builder->limit($limit); + } + + return $builder->get()->getResultArray(); + } + + private function dashboardCounts(string $schoolYear): array + { + return [ + 'enrollments' => $this->countRows('enrollments', ['school_year' => $schoolYear]), + 'open_flags' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']), + 'audits' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear]), + 'exceptions' => $this->countRows('enrollment_exceptions', ['school_year' => $schoolYear]), + 'email_records' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]), + ]; + } + + private function countRows(string $table, array $where): int + { + if (! $this->db->tableExists($table)) { + return 0; + } + + $builder = $this->db->table($table); + foreach ($where as $field => $value) { + $builder->where($field, $value); + } + + return $builder->countAllResults(); + } + + private function printCounts(string $label, array $counts): void + { + CLI::write($label . ' counts:', 'white'); + foreach ($counts as $key => $value) { + CLI::write(' ' . $key . ': ' . $value); + } + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } +} diff --git a/app/Config/App.php b/app/Config/App.php index 58df5ce..15201de 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -215,4 +215,16 @@ class App extends BaseConfig // Sessions (also in App.php) public string $sessionCookieName = '__Host-ci_session'; public bool $sessionRegenerateDestroy = true; + + public function __construct() + { + parent::__construct(); + $isProduction = ENVIRONMENT === 'production'; + $this->forceGlobalSecureRequests = $isProduction; + $this->cookieSecure = $isProduction; + if (! $isProduction) { + $this->cookiePrefix = ''; + $this->sessionCookieName = 'ci_session'; + } + } } diff --git a/app/Config/Cookie.php b/app/Config/Cookie.php index 84ccc0e..525786e 100644 --- a/app/Config/Cookie.php +++ b/app/Config/Cookie.php @@ -56,6 +56,15 @@ class Cookie extends BaseConfig */ public bool $secure = false; + public function __construct() + { + parent::__construct(); + $this->secure = ENVIRONMENT === 'production'; + if ($this->secure) { + $this->prefix = '__Host-'; + } + } + /** * -------------------------------------------------------------------------- * Cookie HTTPOnly diff --git a/app/Config/Cors.php b/app/Config/Cors.php index ae74c26..77b78f8 100644 --- a/app/Config/Cors.php +++ b/app/Config/Cors.php @@ -34,7 +34,7 @@ class Cors extends BaseConfig * - ['http://localhost:8080'] * - ['https://www.example.com'] */ - 'allowedOrigins' => ['*'], // Allow all origins for mobile apps + 'allowedOrigins' => ['*'], /** * Origin regex patterns for the `Access-Control-Allow-Origin` header. @@ -57,7 +57,7 @@ class Cors extends BaseConfig * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials */ - 'supportsCredentials' => true, // Enable for mobile apps using cookies/auth + 'supportsCredentials' => false, /** * Set headers to allow. diff --git a/app/Config/Database.php b/app/Config/Database.php index e9f23f1..abdb7a9 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -29,7 +29,7 @@ class Database extends Config 'DBDriver' => env('database.default.DBDriver', 'MySQLi'), 'DBPrefix' => '', 'pConnect' => false, - 'DBDebug' => (ENVIRONMENT !== 'development'), + 'DBDebug' => (ENVIRONMENT === 'development'), 'charset' => 'utf8', 'DBCollat' => 'utf8_general_ci', 'swapPre' => '', diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php index 2834413..7d4e967 100644 --- a/app/Config/Encryption.php +++ b/app/Config/Encryption.php @@ -23,6 +23,15 @@ class Encryption extends BaseConfig */ public string $key = ''; + public function __construct() + { + parent::__construct(); + $fromEnv = (string) env('encryption.key', env('ENCRYPTION_KEY', '')); + if ($fromEnv !== '') { + $this->key = $fromEnv; + } + } + /** * -------------------------------------------------------------------------- * Encryption Driver to Use diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 0a34179..3a8f234 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -50,10 +50,6 @@ class Filters extends BaseConfig 'invalidchars', 'schoolYearWritable', 'csrf' => ['except' => [ - // WhatsApp membership management (legacy allowances retained) - 'whatsapp/update-membership', - 'index.php/whatsapp/update-membership', - // Attendance management AJAX saves 'attendance/update', 'index.php/attendance/update', diff --git a/app/Config/Routes.php b/app/Config/Routes.php index d845525..4ebbef4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -115,6 +115,12 @@ $routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\Enrol $routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => 'auth:admin']); $routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => 'auth:admin']); $routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/exceptions/create', 'View\EnrollmentAdminController::createException', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => 'auth:admin']); +$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)/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']); // API for report card meta (students, class sections, school years) $routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']); $routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']); @@ -212,6 +218,7 @@ $routes->get('/classes', 'View\UserController::classes'); // Classes page route $routes->get('/contact', 'View\UserController::contact'); // Contact Us page route $routes->post('/user/login', 'AuthController::login'); $routes->post('api/login', 'AuthController::apiLogin'); +$routes->get('admin/api/v1/admin/auth/me', 'AuthController::adminAuthMe'); $routes->get('/welcome_back', 'View\UserController::welcomeBack'); $routes->get('user/forgot_password', 'View\UserController::forgotPassword'); //display forgot password form @@ -571,32 +578,29 @@ $routes->post('grading/release-scores', 'View\GradingController::toggleParentSco $routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']); // Event admin routes -$routes->get('administrator/events', 'View\EventController::index'); -$routes->get('administrator/events/create', 'View\EventController::create'); // show create form -$routes->post('administrator/events/create', 'View\EventController::create'); // handle event create submission -// Route to show form and handle POST in same controller method -$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1'); - - -$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1'); // handle delete +$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin']); +$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']); +$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']); +$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['filter' => 'auth:admin']); $routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); // Parent event participation -$routes->get('administrator/event-charges', 'View\EventController::eventShow'); -$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf'); +$routes->get('administrator/event-charges', 'View\EventController::eventShow', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); +$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); $routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); $routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); $routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); -$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges'); +$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); $routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']); // parent event participation page -$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates +$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation', ['filter' => 'auth:parent']); // Web View Routes for Invoices -$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1'); -$routes->get('invoices/create', 'View\InvoiceController::create'); +$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1', ['filter' => 'auth:parent|view_invoice|view_financial_reports|administrator|administrative staff|principal']); +$routes->get('invoices/create', 'View\InvoiceController::create', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']); $routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); // app/Config/Routes.php @@ -639,8 +643,8 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) { $routes->group('admin', ['filter' => 'auth'], static function ($routes) { $routes->get('broadcast-email', 'View\BroadcastEmailController::index'); $routes->post('broadcast-email/send', 'View\BroadcastEmailController::send'); + $routes->post('broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage'); }); -$routes->post('admin/broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage'); $routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); @@ -651,7 +655,7 @@ $routes->get('payment/download_csv', 'View\FinancialController::downloadCsv', [' $routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->get('administrator/tuition-forecast/export', 'View\TuitionForecastController::exportCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); -$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport'); +$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); // Financial APIs (JSON) $routes->get('api/financial/report', 'View\FinancialController::financialReportData', ['filter' => 'auth']); $routes->get('api/financial/summary', 'View\FinancialController::financialReportSummaryData', ['filter' => 'auth']); @@ -685,20 +689,20 @@ $routes->get('reimbursements/export', 'View\ReimbursementController::export', [' $routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']); // Health check (upload dirs + DB timezone columns) -$routes->get('admin/health', 'View\HealthController::index'); +$routes->get('admin/health', 'View\HealthController::index', ['filter' => 'auth:admin']); //Notifications -$routes->get('notifications/active', 'View\NotificationsController::listActive'); -$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData'); -$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted'); -$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData'); -$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1'); +$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin']); +$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin']); +$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin']); +$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin']); +$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin']); //$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1'); //$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1'); -$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts'); -$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects'); -$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients'); -$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients'); +$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin']); +$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin']); +$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin']); +$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin']); $routes->get( @@ -732,6 +736,8 @@ $routes->post( ['filter' => 'auth:create_invoice'] ); $routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment', ['filter' => 'auth:parent']); // Route for invoice payment page +$routes->get('/parent/financial-aid', 'View\ParentFinancialAidController::index', ['filter' => 'auth:parent']); +$routes->post('/parent/financial-aid', 'View\ParentFinancialAidController::submit', ['filter' => 'auth:parent']); $routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1', ['filter' => 'auth:view_invoice|view_payment|administrator|administrative staff|principal|parent']); // Route for paying the invoice $routes->get('invoices', 'View\InvoiceController::index', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Default route for invoices $routes->get('invoices/(:any)', 'View\InvoiceController::index/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Route with school year filter @@ -783,20 +789,20 @@ $routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController: * routes for View\MessagesController * -------------------------------------------------------------------- */ -$routes->get('messages', 'View\MessagesController::index'); -$routes->post('messages/send', 'View\MessagesController::send'); -$routes->get('/messages/inbox', 'View\MessagesController::inbox'); -$routes->get('/messages/sent', 'View\MessagesController::sent'); -$routes->get('/messages/drafts', 'View\MessagesController::drafts'); -$routes->get('/messages/trash', 'View\MessagesController::trash'); +$routes->get('messages', 'View\MessagesController::index', ['filter' => 'auth']); +$routes->post('messages/send', 'View\MessagesController::send', ['filter' => 'auth']); +$routes->get('/messages/inbox', 'View\MessagesController::inbox', ['filter' => 'auth']); +$routes->get('/messages/sent', 'View\MessagesController::sent', ['filter' => 'auth']); +$routes->get('/messages/drafts', 'View\MessagesController::drafts', ['filter' => 'auth']); +$routes->get('/messages/trash', 'View\MessagesController::trash', ['filter' => 'auth']); // Additional routes for handling actions like viewing, sending, deleting, etc. -$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1'); -$routes->post('/messages/send', 'View\MessagesController::send'); -$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1'); -$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1'); -$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1'); -$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1'); +$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1', ['filter' => 'auth']); +$routes->post('/messages/send', 'View\MessagesController::send', ['filter' => 'auth']); +$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1', ['filter' => 'auth']); +$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1', ['filter' => 'auth']); +$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1', ['filter' => 'auth']); +$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1', ['filter' => 'auth']); /* * -------------------------------------------------------------------- @@ -841,7 +847,7 @@ $routes->group('api/time', ['namespace' => 'App\Controllers\Api'], static functi * routes for View\SchoolCalendarController * -------------------------------------------------------------------- */ -$routes->group('administrator', ['namespace' => 'App\Controllers'], function ($routes) { +$routes->group('administrator', ['namespace' => 'App\Controllers', 'filter' => 'auth:admin'], function ($routes) { $routes->get('calendar_view', 'View\SchoolCalendarController::index'); $routes->get('calendar_edit/(:num)', 'View\SchoolCalendarController::edit/$1'); $routes->get('calendar_delete/(:num)', 'View\SchoolCalendarController::delete/$1'); @@ -858,52 +864,52 @@ $routes->group('administrator', ['namespace' => 'App\Controllers'], function ($r * routes for Parent Pages * -------------------------------------------------------------------- */ -$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent'); -$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent'); -$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent'); +$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent', ['filter' => 'auth:parent']); +$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent', ['filter' => 'auth:parent']); +$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent', ['filter' => 'auth:parent']); -$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses'); -$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler'); -$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess'); -$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure'); -$routes->get('/parent/payment', 'View\ParentController::viewPayments'); -$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView'); -$routes->get('/parent/attendance', 'View\ParentController::attendance'); +$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/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']); +$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView', ['filter' => 'auth:parent']); +$routes->get('/parent/attendance', 'View\ParentController::attendance', ['filter' => 'auth:parent']); // Parent reporting: absence/late/early dismissal -$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form'); -$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit'); -$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update'); -$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list'); +$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form', ['filter' => 'auth:parent']); +$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['filter' => 'auth:parent']); +$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update', ['filter' => 'auth:parent']); +$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list', ['filter' => 'auth:admin']); // Admin/Teacher: early dismissal page by month -$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals'); +$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals', ['filter' => 'auth:admin']); // Admin/Teacher: add early dismissal -$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm'); -$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal'); -$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature'); +$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin']); +$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin']); +$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin']); // Parent report: client-side check API -$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting'); +$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting', ['filter' => 'auth:parent']); $routes->get('/support', 'View\StatsController::support'); -$routes->get('no-kids', 'View\ParentController::noKids'); -$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1'); -$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal'); -$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents'); +$routes->get('no-kids', 'View\ParentController::noKids', ['filter' => 'auth:parent']); +$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']); +$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal', ['filter' => 'auth:parent']); +$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents', ['filter' => 'auth:parent']); // Show registration form with dynamic student and emergency contact blocks -$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm'); -$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck'); +$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm', ['filter' => 'auth:parent']); +$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck', ['filter' => 'auth:parent']); // Handle form submission (students + emergency contacts) -$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration'); -$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1'); -$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1'); -$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1'); -$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1'); +$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration', ['filter' => 'auth:parent']); +$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1', ['filter' => 'auth:parent']); +$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']); +$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']); +$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1', ['filter' => 'auth:parent']); -$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1'); -$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1'); +$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']); +$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']); /*management navigation bar*/ @@ -915,7 +921,7 @@ $routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', // Inventory routes -$routes->group('inventory', ['filter' => 'csrf'], static function ($routes) { +$routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|administrative staff|principal'], static function ($routes) { /** --------------------------- * Movements (put FIRST) @@ -1013,13 +1019,13 @@ $routes->group('communications', static function ($routes) { //////////////////////////////////////////////// // app/Config/Routes.php -$routes->group('api', static function ($routes) { +$routes->group('api', ['filter' => 'auth:admin'], static function ($routes) { // Families & Guardians API $routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$1'); $routes->get('families/(:num)/guardians', 'View\FamilyController::guardiansByFamily/$1'); }); -$routes->group('families', static function ($routes) { +$routes->group('families', ['filter' => 'auth:admin'], static function ($routes) { // Allow GET for convenience in browser; keep POST for API/automation $routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production $routes->post('attach-second-by-user', 'View\FamilyController::attachSecondByUser'); @@ -1031,10 +1037,10 @@ $routes->group('families', static function ($routes) { $routes->post('unlink-student', 'View\FamilyController::unlinkStudent'); }); // Allow GET for manual triggering from browser -$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy'); +$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy', ['filter' => 'auth:admin']); // Admin page (protect with your auth/permission) -$routes->group('family', static function ($routes) { +$routes->group('family', ['filter' => 'auth:admin'], static function ($routes) { $routes->get('', 'View\FamilyAdminController::index'); $routes->get('index', 'View\FamilyAdminController::index'); $routes->get('search', 'View\FamilyAdminController::search'); @@ -1043,13 +1049,13 @@ $routes->group('family', static function ($routes) { $routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail'); }); // Convenience alias -$routes->get('family', 'View\FamilyAdminController::index'); +$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin']); ////////////////////////////////////////////////////////// //upload files $routes->get('receipts/(:any)', 'View\FilesController::receipt/$1', ['filter' => 'auth']); $routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1', ['filter' => 'auth']); // serves from writable/uploads/reimbursements -$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1'); +$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1', ['filter' => 'auth']); // Expenses $routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']); $routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']); @@ -1061,13 +1067,13 @@ $routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::upd // app/Config/Routes.php -$routes->get('whatsapp/', 'View\WhatsappController::index'); -$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites'); -$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink'); -$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts'); -$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass'); +$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin']); +$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin']); +$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin']); +$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin']); +$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin']); // Track WhatsApp group membership per class/parent -$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership'); +$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership', ['filter' => 'auth:admin']); @@ -1107,15 +1113,15 @@ $routes->post('admin/teacher-attendance/save-cell', 'View\AttendanceController:: $routes->post('admin/admins-attendance/save-cell', 'View\AttendanceController::saveCell'); -$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact'); -$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1'); +$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact', ['filter' => 'auth:parent']); +$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']); $routes->get('rfid_coming_soon', 'View\RFIDController::rfidComingSoon'); -$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact'); -$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm'); +$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact', ['filter' => 'auth:parent']); +$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm', ['filter' => 'auth:parent']); $routes->get('report/combined', 'View\ScorePredictor::combinedReport'); @@ -1123,11 +1129,11 @@ $routes->get('report/combined', 'View\ScorePredictor::combinedReport'); //$routes->get('/parent/withdraw_success', 'View\ParentController::withdraw_success'); -$routes->get('/profile/(:num)', 'View\ParentController::profile/$1'); -$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1'); +$routes->get('/profile/(:num)', 'View\ParentController::profile/$1', ['filter' => 'auth']); +$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1', ['filter' => 'auth']); -$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1'); -$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1'); +$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1', ['filter' => 'auth']); +$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1', ['filter' => 'auth']); // Route to view_add_edit the configuration page $routes->get('/configuration/configuration_view', 'View\ConfigurationController::index'); @@ -1248,7 +1254,7 @@ $routes->post('/register', 'View\RegisterController::register'); $routes->get('/register/success', 'View\RegisterController::success'); -$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm'); +$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm', ['filter' => 'auth:parent']); $routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin', ['filter' => 'auth:admin_dashboard,read']); diff --git a/app/Config/Services.php b/app/Config/Services.php index 77d9c2b..97ec497 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -70,21 +70,26 @@ class Services extends BaseService $mail = new PHPMailer(true); - // Set up your PHPMailer configuration + $host = (string) env('MAIL_DEFAULT_HOST', env('SMTP_HOST', 'smtp.gmail.com')); + $user = (string) env('MAIL_DEFAULT_USER', env('SMTP_USER', '')); + $pass = (string) env('MAIL_DEFAULT_PASS', env('SMTP_PASS', '')); + $port = (int) env('MAIL_DEFAULT_PORT', env('SMTP_PORT', 465)); + $encryption = strtolower((string) env('MAIL_DEFAULT_ENCRYPTION', env('SMTP_ENCRYPTION', 'ssl'))); + $mail->isSMTP(); - $mail->Host = 'smtp.gmail.com'; // Your SMTP host + $mail->Host = $host; $mail->SMTPAuth = true; - $mail->Username = 'alrahma.sunday.school@gmail.com'; // Your email - $mail->Password = 'psnp emdq dykw ypul'; // Your email password - $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; - $mail->Port = 465; + $mail->Username = $user; + $mail->Password = $pass; + $mail->SMTPSecure = in_array($encryption, ['tls', 'starttls'], true) + ? PHPMailer::ENCRYPTION_STARTTLS + : PHPMailer::ENCRYPTION_SMTPS; + $mail->Port = $port > 0 ? $port : 465; - $mail->Timeout = 10; // ⏱ 10-second timeout max - $mail->SMTPKeepAlive = false; // Prevent hanging connections - $mail->SMTPDebug = 0; // Set to 2 temporarily for debugging - - $mail->SMTPDebug = 2; // You can set it to 1, 2, or 3 for increasing verbosity - $mail->Debugoutput = 'html'; // You can output it to 'html' or 'error_log' + $mail->Timeout = 10; + $mail->SMTPKeepAlive = false; + $mail->SMTPDebug = 0; + $mail->Debugoutput = 'error_log'; return $mail; } @@ -274,7 +279,6 @@ class Services extends BaseService model(\App\Models\SchoolYearModel::class), model(\App\Models\SchoolYearClosingBatchModel::class), model(\App\Models\SchoolYearClosingItemModel::class), - model(\App\Models\ConfigurationModel::class), static::schoolYearManagement(), \Config\Database::connect() ); @@ -301,4 +305,19 @@ class Services extends BaseService static::emailService() ); } + + public static function financialAid(bool $getShared = true): \App\Services\FinancialAidService + { + if ($getShared) { + return static::getSharedInstance('financialAid'); + } + + return new \App\Services\FinancialAidService( + model(\App\Models\FinancialAidRequestModel::class), + model(\App\Models\InvoiceModel::class), + model(\App\Models\DiscountVoucherModel::class), + model(\App\Models\DiscountUsageModel::class), + new \App\Libraries\InvoiceLedgerService() + ); + } } diff --git a/app/Config/Session.php b/app/Config/Session.php index a44bbd1..4ae9d60 100644 --- a/app/Config/Session.php +++ b/app/Config/Session.php @@ -32,7 +32,7 @@ class Session extends BaseConfig */ public string $cookieName = 'ci_session'; public string $cookieDomain = ''; // Leave blank for localhost - public bool $cookieSecure = false; // Set to false if not using HTTPS + public bool $cookieSecure = false; // Forced on in production via constructor /** * -------------------------------------------------------------------------- @@ -101,4 +101,12 @@ class Session extends BaseConfig * DB Group for the database session. */ public ?string $DBGroup = null; + + public function __construct() + { + parent::__construct(); + $isProduction = ENVIRONMENT === 'production'; + $this->cookieSecure = $isProduction; + $this->cookieName = $isProduction ? '__Host-ci_session' : 'ci_session'; + } } \ No newline at end of file diff --git a/app/Controllers/Administrator/FinancialAidController.php b/app/Controllers/Administrator/FinancialAidController.php new file mode 100644 index 0000000..c912634 --- /dev/null +++ b/app/Controllers/Administrator/FinancialAidController.php @@ -0,0 +1,106 @@ +request->getGet('school_year'); + $model = new FinancialAidRequestModel(); + $builder = $model->orderBy('created_at', 'DESC'); + if (is_string($schoolYear) && trim($schoolYear) !== '') { + $builder->where('school_year', trim($schoolYear)); + } + + $requests = $builder->findAll(); + $parentIds = array_values(array_unique(array_filter(array_map('intval', array_column($requests, 'parent_id'))))); + $parents = []; + if ($parentIds !== []) { + foreach ((new UserModel())->select('id, firstname, lastname, email')->whereIn('id', $parentIds)->findAll() as $parent) { + $parents[(int) $parent['id']] = $parent; + } + } + + return view('administrator/financial_aid_queue', [ + 'requests' => $requests, + 'parents' => $parents, + 'schoolYear' => is_string($schoolYear) ? trim($schoolYear) : '', + ]); + } + + public function show(int $id) + { + $request = (new FinancialAidRequestModel())->find($id); + if ($request === null) { + return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.'); + } + + $parent = (new UserModel())->find((int) $request['parent_id']); + $studentIds = json_decode((string) ($request['student_ids_json'] ?? '[]'), true); + $studentIds = is_array($studentIds) ? array_map('intval', $studentIds) : []; + $students = $studentIds !== [] + ? (new StudentModel())->whereIn('id', $studentIds)->findAll() + : []; + + return view('administrator/financial_aid_review', [ + 'requestRow' => $request, + 'parent' => $parent, + 'students' => $students, + ]); + } + + public function approve(int $id) + { + try { + $model = new FinancialAidRequestModel(); + $request = $model->find($id); + if ($request === null) { + return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.'); + } + if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) { + return redirect()->back()->with('error', 'Only open requests can be approved.'); + } + + $amount = (float) $this->request->getPost('admin_amount'); + $note = trim((string) $this->request->getPost('admin_note')); + service('financialAid')->applyApprovedAmount($request, $amount, (int) session()->get('user_id'), $note); + + return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid was approved and applied to the invoice.'); + } catch (Throwable $e) { + return redirect()->back()->withInput()->with('error', $e->getMessage()); + } + } + + public function deny(int $id) + { + $model = new FinancialAidRequestModel(); + $request = $model->find($id); + if ($request === null) { + return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.'); + } + if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) { + return redirect()->back()->with('error', 'Only open requests can be denied.'); + } + + $note = trim((string) $this->request->getPost('admin_note')); + if ($note === '') { + return redirect()->back()->with('error', 'A denial note is required.'); + } + + $model->update($id, [ + 'status' => 'denied', + 'admin_note' => $note, + 'reviewed_by' => (int) session()->get('user_id'), + 'reviewed_at' => date('Y-m-d H:i:s'), + ]); + + return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid request was denied.'); + } +} diff --git a/app/Controllers/Administrator/SchoolYearClosingController.php b/app/Controllers/Administrator/SchoolYearClosingController.php index f5660af..699c415 100644 --- a/app/Controllers/Administrator/SchoolYearClosingController.php +++ b/app/Controllers/Administrator/SchoolYearClosingController.php @@ -4,6 +4,7 @@ namespace App\Controllers\Administrator; use App\Controllers\BaseController; use App\Models\SchoolYearModel; +use App\Support\SchoolYear\SchoolYearStatus; use Throwable; class SchoolYearClosingController extends BaseController @@ -12,6 +13,11 @@ class SchoolYearClosingController extends BaseController { try { $targetId = $this->normalizeInt($this->request->getGet('target_school_year_id')); + $source = (new SchoolYearModel())->find($id); + if ($targetId === null && (string) ($source['status'] ?? '') === SchoolYearStatus::ACTIVE) { + $target = service('schoolYearManagement')->ensureNextDraftForClosing($id, $this->userId()); + $targetId = (int) ($target['id'] ?? 0) ?: null; + } $preview = service('schoolYearClosing')->preview($id, $targetId); $promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []); $carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []); diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 6247fc3..5e16642 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -252,6 +252,32 @@ class AuthController extends BaseController ]); } + public function adminAuthMe() + { + if (! session()->get('is_logged_in')) { + return $this->response->setStatusCode(401)->setJSON([ + 'status' => false, + 'message' => 'Unauthenticated.', + ]); + } + + $userId = (int) session()->get('user_id'); + $roles = array_values(array_filter((array) (session()->get('roles') ?? []))); + $activeRole = session()->get('role'); + + return $this->response->setJSON([ + 'status' => true, + 'user' => [ + 'id' => $userId, + 'email' => session()->get('user_email'), + 'name' => session()->get('user_name'), + 'type' => session()->get('user_type'), + 'roles' => $roles, + 'role' => $activeRole, + ], + ]); + } + /** * API Registration endpoint * POST /api/v1/register @@ -552,6 +578,7 @@ class AuthController extends BaseController $roleNames = array_column($roles, 'name'); + session()->regenerate(true); session()->set([ 'user_id' => $user['id'], 'user_email' => $user['email'], @@ -565,8 +592,6 @@ class AuthController extends BaseController 'school_year' => $this->schoolYear, ]); $this->applyStylePreferences((int) $user['id']); - log_message('debug', 'Session after login: ' . print_r(session()->get(), true)); - //dd('Login successful. Roles:', $roleNames, session()->get()); if (count($roleNames) === 1) { // One role → set and redirect directly diff --git a/app/Controllers/View/AuthorizedUsersController.php b/app/Controllers/View/AuthorizedUsersController.php index a6cc46c..c95b208 100644 --- a/app/Controllers/View/AuthorizedUsersController.php +++ b/app/Controllers/View/AuthorizedUsersController.php @@ -15,7 +15,7 @@ class AuthorizedUsersController extends ResourceController protected $userModel; protected $authorizedUserModel; - public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request) + public function __construct() { $this->userModel = new UserModel(); $this->authorizedUserModel = new AuthorizedUserModel(); diff --git a/app/Controllers/View/ClassController.php b/app/Controllers/View/ClassController.php index 413e349..192de23 100644 --- a/app/Controllers/View/ClassController.php +++ b/app/Controllers/View/ClassController.php @@ -18,7 +18,7 @@ class ClassController extends BaseController protected $db; - public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request) + public function __construct() { $this->db = \Config\Database::connect(); $this->classsectionModel = new ClassSectionModel(); diff --git a/app/Controllers/View/ContactController.php b/app/Controllers/View/ContactController.php index bdd597e..597ce3a 100644 --- a/app/Controllers/View/ContactController.php +++ b/app/Controllers/View/ContactController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; class ContactController extends BaseController { - public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request) + public function __construct() { helper('form'); // Load the form helper } diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php index 4bb58e0..acfa78e 100644 --- a/app/Controllers/View/EnrollmentAdminController.php +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -26,8 +26,14 @@ class EnrollmentAdminController extends BaseController $status = trim((string) ($this->request->getGet('status') ?? 'open')); $flagType = trim((string) ($this->request->getGet('flag_type') ?? '')); $assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? '')); + $exceptionParentId = (int) ($this->request->getGet('exception_parent_id') ?? 0); + $canManageExceptions = $this->canManageEnrollmentExceptions(); + service('enrollmentTransition')->syncDashboardBlockageFlags($schoolYear); $flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo); + $openFlags = ($status === 'open' && $flagType === '' && $assignedTo === '') + ? $flags + : $this->enrollmentFlags($schoolYear, 'open', '', ''); return view('administrator/enrollment_admin_dashboard', [ 'flags' => $flags, @@ -39,11 +45,19 @@ class EnrollmentAdminController extends BaseController 'schoolYears' => $this->schoolYears(), 'classSections' => $this->classSections($schoolYear), 'admins' => $this->adminUsers(), - 'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear), + 'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear, $openFlags), 'auditRows' => $this->auditRows($schoolYear), + 'activeExceptions' => $canManageExceptions ? $this->enrollmentExceptions($schoolYear) : [], + 'exceptionNeeded' => $this->exceptionNeededFromFlags($openFlags), + 'canManageEnrollmentExceptions' => $canManageExceptions, + 'exceptionFamilies' => $this->exceptionFamilies(), + 'exceptionReasonCodes' => $this->exceptionReasonCodes(), + 'exceptionSelectedParentId' => $exceptionParentId, + 'exceptionPreview' => $this->exceptionPreview($exceptionParentId, $schoolYear), 'launchState' => $this->launchState($schoolYear), 'previewParentId' => $this->firstParentWithStudents(), 'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear), + 'openFlagCount' => $this->openFlagCount($schoolYear), ]); } @@ -202,28 +216,257 @@ class EnrollmentAdminController extends BaseController public function approveException(int $id) { try { + if (! $this->canManageEnrollmentExceptions()) { + return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.'); + } + $flag = $this->requireFlag($id); $reason = trim((string) ($this->request->getPost('reason') ?? '')); if ($reason === '') { return redirect()->back()->with('error', 'Approval reason is required.'); } + $parentId = $this->parentIdForStudent((int) $flag['student_id'], (string) $flag['school_year']); + if ($parentId <= 0) { + return redirect()->back()->with('error', 'Unable to create exception because no linked parent was found.'); + } + + $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(); + + if ($this->db->tableExists('enrollment_exceptions')) { + $existing = $this->db->table('enrollment_exceptions') + ->where('parent_id', $parentId) + ->where('student_id', (int) $flag['student_id']) + ->where('school_year', (string) $flag['school_year']) + ->where('status', 'active') + ->limit(1) + ->get() + ->getRowArray(); + + $payload = [ + 'parent_id' => $parentId, + 'student_id' => (int) $flag['student_id'], + 'school_year' => (string) $flag['school_year'], + 'source_school_year' => $flag['source_school_year'] ?? null, + 'status' => 'active', + 'reason_code' => (string) $flag['flag_type'], + 'reason_note' => $reason, + 'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES), + 'family_student_ids_json' => json_encode([(int) $flag['student_id']], JSON_UNESCAPED_SLASHES), + 'created_by' => $this->userId(), + 'approved_by' => $this->userId(), + 'starts_at' => $now, + 'expires_at' => $expiresAt, + 'updated_at' => $now, + ]; + + if ($existing !== null) { + $this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload); + } else { + $payload['created_at'] = $now; + $this->db->table('enrollment_exceptions')->insert($payload); + } + } + $this->db->table('enrollments') ->where('student_id', (int) $flag['student_id']) ->where('school_year', (string) $flag['school_year']) ->update([ 'exception_required' => 0, 'exception_reason' => $reason, - 'updated_at' => date('Y-m-d H:i:s'), + 'updated_at' => $now, ]); $this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved'); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return redirect()->back()->with('error', 'Unable to approve enrollment exception.'); + } + return redirect()->back()->with('success', 'Enrollment exception approved.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } + public function createException() + { + try { + if (! $this->canManageEnrollmentExceptions()) { + return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.'); + } + + if (! $this->db->tableExists('enrollment_exceptions')) { + return redirect()->back()->with('error', 'Enrollment exception storage is not available. Run migrations first.'); + } + + $parentId = (int) ($this->request->getPost('parent_id') ?? 0); + $studentIds = $this->request->getPost('student_ids') ?? []; + if (! is_array($studentIds)) { + $studentIds = [$this->request->getPost('student_id') ?? 0]; + } + $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0))); + $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 : []; + + if ($parentId <= 0 || $studentIds === [] || $schoolYear === '' || $reasonCode === '' || $reasonNote === '') { + return redirect()->back()->withInput()->with('error', 'Parent, at least one student, school year, reason code, and note are required.'); + } + + $sourceSchoolYear = $this->previousSchoolYearName($schoolYear); + if ($sourceSchoolYear === null) { + 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; + $this->db->transStart(); + + foreach ($studentIds as $studentId) { + if (! $this->studentLinkedToParent($studentId, $parentId)) { + $this->db->transRollback(); + return redirect()->back()->withInput()->with('error', 'Selected student is not linked to the selected parent.'); + } + + $evaluation = $transitionService->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $schoolYear, 'admin'); + $failedCodes = array_values(array_unique(array_filter(array_map('strval', array_merge( + $evaluation['blocking_rule_codes'] ?? [], + $evaluation['review_rule_codes'] ?? [] + ))))); + + $postedCodes = $postedCodesByStudent[(string) $studentId] ?? $postedCodesByStudent[$studentId] ?? []; + $postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : []; + $postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes))); + $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'])); + if ($nonOverridable !== []) { + $this->db->transRollback(); + return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable)); + } + + if ($ruleCodes === []) { + $this->db->transRollback(); + return redirect()->back()->withInput()->with('error', 'No failed eligibility rule codes were selected for one or more selected students.'); + } + + $existing = $this->db->table('enrollment_exceptions') + ->where('parent_id', $parentId) + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('status', 'active') + ->limit(1) + ->get() + ->getRowArray(); + + $payload = [ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'source_school_year' => $sourceSchoolYear, + 'status' => 'active', + 'reason_code' => $reasonCode, + 'reason_note' => $reasonNote, + 'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES), + 'family_student_ids_json' => json_encode([$studentId], JSON_UNESCAPED_SLASHES), + 'created_by' => $this->userId(), + 'approved_by' => $this->userId(), + 'starts_at' => $now, + 'expires_at' => $expiresAt, + 'updated_at' => $now, + ]; + + if ($existing !== null) { + $this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload); + $exceptionId = (int) $existing['id']; + } else { + $payload['created_at'] = $now; + $this->db->table('enrollment_exceptions')->insert($payload); + $exceptionId = (int) $this->db->insertID(); + } + + $this->audit($studentId, $schoolYear, $sourceSchoolYear, 'enrollment_exception_created', $existing, array_merge($payload, [ + 'id' => $exceptionId, + 'evaluation_decision' => $evaluation['decision'] ?? null, + ]), $reasonNote); + $this->resolveCoveredFlags($studentId, $schoolYear, $ruleCodes, $reasonNote); + $saved++; + } + + $this->db->transComplete(); + if ($this->db->transStatus() === false) { + return redirect()->back()->withInput()->with('error', 'Unable to save enrollment exception.'); + } + + return redirect()->to(site_url('administrator/enrollment-admin?school_year=' . rawurlencode($schoolYear) . '&exception_parent_id=' . $parentId)) + ->with('success', $saved . ' enrollment exception' . ($saved === 1 ? '' : 's') . ' saved.'); + } catch (Throwable $e) { + return redirect()->back()->withInput()->with('error', $e->getMessage()); + } + } + + public function revokeException(int $id) + { + if (! $this->canManageEnrollmentExceptions()) { + return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.'); + } + + if ($id <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { + return redirect()->back()->with('error', 'Enrollment exception was not found.'); + } + + $reason = trim((string) ($this->request->getPost('revocation_reason') ?? '')); + if ($reason === '') { + return redirect()->back()->with('error', 'Revocation reason is required.'); + } + + $exception = $this->db->table('enrollment_exceptions')->where('id', $id)->limit(1)->get()->getRowArray(); + if ($exception === null || (string) ($exception['status'] ?? '') !== 'active') { + return redirect()->back()->with('error', 'Only active exceptions can be revoked.'); + } + + $this->db->table('enrollment_exceptions')->where('id', $id)->update([ + 'status' => 'revoked', + 'revoked_at' => date('Y-m-d H:i:s'), + 'revoked_by' => $this->userId(), + 'revocation_reason' => $reason, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $this->audit( + (int) $exception['student_id'], + (string) $exception['school_year'], + (string) ($exception['source_school_year'] ?? ''), + 'enrollment_exception_revoked', + $exception, + ['status' => 'revoked', 'revocation_reason' => $reason], + $reason + ); + + return redirect()->back()->with('success', 'Enrollment exception revoked.'); + } + private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array { if (! $this->db->tableExists('enrollment_flags')) { @@ -232,10 +475,12 @@ class EnrollmentAdminController extends BaseController $builder = $this->db->table('enrollment_flags ef') ->select('ef.*') - ->select('s.firstname, s.lastname, s.school_id') + ->select('s.firstname, s.lastname, s.school_id, s.parent_id') ->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname') + ->select('p.firstname AS parent_firstname, p.lastname AS parent_lastname') ->join('students s', 's.id = ef.student_id', 'left') ->join('users u', 'u.id = ef.assigned_to', 'left') + ->join('users p', 'p.id = s.parent_id', 'left') ->orderBy('ef.created_at', 'DESC') ->orderBy('ef.id', 'DESC'); @@ -256,6 +501,7 @@ class EnrollmentAdminController extends BaseController 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'] ?? '')); + $row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: ((int) ($row['parent_id'] ?? 0) > 0 ? 'Parent #' . (int) $row['parent_id'] : ''); $row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: []; } unset($row); @@ -263,10 +509,10 @@ class EnrollmentAdminController extends BaseController return $rows; } - private function enrollmentFollowups(string $schoolYear): array + private function enrollmentFollowups(string $schoolYear, array $flags = []): array { if (! $this->db->tableExists('enrollments')) { - return []; + return $this->followupsFromFlags($flags, []); } $fields = $this->db->getFieldNames('enrollments'); @@ -338,10 +584,16 @@ class EnrollmentAdminController extends BaseController if (in_array('deliberation_decision', $fields, true)) { $builder->orWhereIn('e.deliberation_decision', [ 'make_up_exam', + 'MAKE_UP_EXAM', 'repeat_class', + 'REPEAT_CLASS', 'deferred', + 'DEFERRED', + 'DEFERRED_DECISION', 'expelled', + 'EXPELLED', 'withdrawn', + 'WITHDRAWN', ]); } @@ -351,9 +603,76 @@ class EnrollmentAdminController extends BaseController } unset($row); + return $this->followupsFromFlags($flags, $rows); + } + + private function followupsFromFlags(array $flags, array $rows): array + { + $seen = []; + foreach ($rows as $row) { + $seen[(int) ($row['student_id'] ?? 0)] = true; + } + + $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', + ]; + + foreach ($flags as $flag) { + $type = (string) ($flag['flag_type'] ?? ''); + $studentId = (int) ($flag['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId]) || ! isset($followupTypes[$type])) { + continue; + } + + $seen[$studentId] = true; + $rows[] = [ + 'id' => 0, + 'student_id' => $studentId, + 'student_name' => (string) ($flag['student_name'] ?? ''), + 'school_id' => (string) ($flag['school_id'] ?? ''), + 'enrollment_status' => 'not enrolled', + 'deliberation_decision' => $type === 'PENDING_MAKE_UP_EXAM_PROMOTION' ? 'MAKE_UP_EXAM' : '', + 'placement_status' => $followupTypes[$type], + 'class_section_name' => '', + 'exception_required' => 0, + 'exception_reason' => $type, + 'updated_at' => $flag['created_at'] ?? null, + ]; + } + return $rows; } + private function exceptionNeededFromFlags(array $flags): array + { + $exceptionTypes = [ + 'AGE_EXCEPTION_REQUIRED', + 'LATE_REGISTRATION_EXCEPTION', + 'FINANCIAL_REVIEW_REQUIRED', + 'CLASS_CAPACITY_EXCEPTION_REQUIRED', + 'SIBLING_LAST_NAME_MISMATCH', + 'DEFERRED_DELIBERATION', + 'WITHDRAWAL_REVIEW_REQUIRED', + 'RESTRICTED_ADMINISTRATIVE_REVIEW', + ]; + + $needed = []; + foreach ($flags as $flag) { + if (($flag['status'] ?? 'open') !== 'open') { + continue; + } + if (! in_array((string) ($flag['flag_type'] ?? ''), $exceptionTypes, true)) { + continue; + } + $needed[] = $flag; + } + + return $needed; + } + private function requireFlag(int $id): array { if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) { @@ -392,6 +711,29 @@ class EnrollmentAdminController extends BaseController } } + private function resolveCoveredFlags(int $studentId, string $schoolYear, array $ruleCodes, string $notes): void + { + if ($studentId <= 0 || $schoolYear === '' || $ruleCodes === [] || ! $this->db->tableExists('enrollment_flags')) { + return; + } + + $ruleCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $ruleCodes))); + $flags = $this->db->table('enrollment_flags') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('status', 'open') + ->get() + ->getResultArray(); + + foreach ($flags as $flag) { + $flagType = strtoupper(trim((string) ($flag['flag_type'] ?? ''))); + $mapped = array_map('strtoupper', $this->ruleCodesForFlag($flagType)); + if (in_array($flagType, $ruleCodes, true) || array_intersect($mapped, $ruleCodes) !== []) { + $this->resolveFlagRow($flag, $notes, 'enrollment_exception_created'); + } + } + } + private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void { $section = $this->db->table('classSection') @@ -514,6 +856,270 @@ class EnrollmentAdminController extends BaseController return $rows; } + private function enrollmentExceptions(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_exceptions')) { + return []; + } + + $builder = $this->db->table('enrollment_exceptions ee') + ->select('ee.*') + ->select('s.firstname, s.lastname, s.school_id') + ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname') + ->select('admin.firstname AS admin_firstname, admin.lastname AS admin_lastname') + ->join('students s', 's.id = ee.student_id', 'left') + ->join('users u', 'u.id = ee.parent_id', 'left') + ->join('users admin', 'admin.id = ee.created_by', 'left') + ->orderBy('ee.created_at', 'DESC') + ->orderBy('ee.id', 'DESC') + ->limit(100); + + if ($schoolYear !== '') { + $builder->where('ee.school_year', $schoolYear); + } + + $rows = $builder->get()->getResultArray(); + foreach ($rows as &$row) { + $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); + $row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) ($row['parent_id'] ?? 0); + $row['created_by_name'] = trim((string) ($row['admin_firstname'] ?? '') . ' ' . (string) ($row['admin_lastname'] ?? '')) ?: ((int) ($row['created_by'] ?? 0) > 0 ? 'User #' . (int) $row['created_by'] : ''); + $decoded = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true); + $row['bypassed_rule_codes'] = is_array($decoded) ? array_values(array_map('strval', $decoded)) : []; + } + unset($row); + + return $rows; + } + + private function exceptionFamilies(): array + { + if (! $this->db->tableExists('students') || ! $this->db->tableExists('users')) { + return []; + } + + $rows = $this->db->table('students s') + ->select('s.id AS student_id, s.firstname AS student_firstname, s.lastname AS student_lastname, s.school_id, s.parent_id') + ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email') + ->join('users u', 'u.id = s.parent_id', 'left') + ->where('s.parent_id >', 0) + ->orderBy('u.lastname', 'ASC') + ->orderBy('u.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->limit(1000) + ->get() + ->getResultArray(); + + $families = []; + foreach ($rows as $row) { + $parentId = (int) ($row['parent_id'] ?? 0); + if ($parentId <= 0) { + continue; + } + + if (! isset($families[$parentId])) { + $parentName = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')); + $families[$parentId] = [ + 'parent_id' => $parentId, + 'parent_name' => $parentName !== '' ? $parentName : 'Parent #' . $parentId, + 'parent_email' => (string) ($row['parent_email'] ?? ''), + 'students' => [], + ]; + } + + $studentId = (int) ($row['student_id'] ?? 0); + $studentName = trim((string) ($row['student_firstname'] ?? '') . ' ' . (string) ($row['student_lastname'] ?? '')); + $families[$parentId]['students'][] = [ + 'student_id' => $studentId, + 'student_name' => $studentName !== '' ? $studentName : 'Student #' . $studentId, + 'school_id' => (string) ($row['school_id'] ?? ''), + ]; + } + + $families = array_values($families); + usort($families, static function (array $left, array $right): int { + return strnatcasecmp( + (string) ($left['parent_name'] ?? ''), + (string) ($right['parent_name'] ?? '') + ); + }); + + return $families; + } + + private function exceptionReasonCodes(): array + { + return [ + 'ADMIN_REVIEW_APPROVED' => 'Administrative review approved', + 'FINANCE_APPROVAL_REQUIRED' => 'Finance approval', + 'SIBLING_LAST_NAME_REVIEWED' => 'Sibling last name reviewed', + 'AGE_EXCEPTION_APPROVED' => 'Age exception approved', + 'LATE_REGISTRATION_APPROVED' => 'Late registration approved', + 'ACADEMIC_STATUS_EXCEPTION' => 'Academic status exception', + 'MANUAL_PLACEMENT_EXCEPTION' => 'Manual placement exception', + ]; + } + + private function exceptionPreview(int $parentId, string $schoolYear): ?array + { + if ($parentId <= 0 || $schoolYear === '') { + return null; + } + + $sourceSchoolYear = $this->previousSchoolYearName($schoolYear); + if ($sourceSchoolYear === null) { + return [ + 'error' => 'Unable to determine the previous school year for ' . $schoolYear . '.', + ]; + } + + $parent = $this->db->table('users u') + ->select('u.firstname, u.lastname, u.email') + ->where('u.id', $parentId) + ->limit(1) + ->get() + ->getRowArray() ?: []; + + $studentRows = $this->db->table('students s') + ->select('s.id, s.firstname, s.lastname, s.school_id') + ->where('s.parent_id', $parentId) + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->get() + ->getResultArray(); + $students = []; + foreach ($studentRows as $student) { + $linkedStudentId = (int) ($student['id'] ?? 0); + if ($linkedStudentId <= 0) { + continue; + } + + $students[] = [ + 'student_id' => $linkedStudentId, + 'student_name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $linkedStudentId, + 'school_id' => (string) ($student['school_id'] ?? ''), + 'evaluation' => service('enrollmentTransition')->evaluateForParent($parentId, $linkedStudentId, $sourceSchoolYear, $schoolYear, 'admin'), + ]; + } + + return [ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'source_school_year' => $sourceSchoolYear, + 'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')) ?: 'Parent #' . $parentId, + 'parent_email' => (string) ($parent['email'] ?? ''), + 'students' => $students, + ]; + } + + private function studentLinkedToParent(int $studentId, int $parentId): bool + { + if ($studentId <= 0 || $parentId <= 0 || ! $this->db->tableExists('students')) { + return false; + } + + return $this->db->table('students') + ->where('id', $studentId) + ->where('parent_id', $parentId) + ->countAllResults() > 0; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + $schoolYear = trim($schoolYear); + if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) { + return null; + } + + return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); + } + + private function parentIdForStudent(int $studentId, string $schoolYear): int + { + if ($studentId <= 0) { + return 0; + } + + if ($schoolYear !== '' && $this->db->tableExists('enrollments')) { + $row = $this->db->table('enrollments') + ->select('parent_id') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if ((int) ($row['parent_id'] ?? 0) > 0) { + return (int) $row['parent_id']; + } + } + + $student = $this->db->table('students') + ->select('parent_id') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray(); + + return (int) ($student['parent_id'] ?? 0); + } + + private function ruleCodesForFlag(string $flagType): array + { + return match ($flagType) { + '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'], + 'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH'], + default => [$flagType], + }; + } + + private function canManageEnrollmentExceptions(): bool + { + $userId = $this->userId(); + if ($userId <= 0) { + return false; + } + + if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions') || ! $this->db->tableExists('user_roles')) { + return $this->isAdministratorSessionRole(); + } + + $rows = $this->db->table('user_roles ur') + ->select('rp.*') + ->join('role_permissions rp', 'rp.role_id = ur.role_id') + ->join('permissions p', 'p.id = rp.permission_id') + ->where('ur.user_id', $userId) + ->where('LOWER(p.name)', 'enrollment.exception.manage') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + if (! empty($row['can_create']) || ! empty($row['can_update']) || ! empty($row['can_delete'])) { + return true; + } + } + + return false; + } + + private function isAdministratorSessionRole(): bool + { + $roles = array_map(static fn ($role): string => strtolower(trim((string) $role)), (array) session()->get('roles')); + $activeRole = strtolower(trim((string) session()->get('role'))); + if ($activeRole !== '') { + $roles[] = $activeRole; + } + + return (bool) array_intersect(array_unique($roles), ['administrator', 'principal']); + } + private function launchState(string $schoolYear): array { if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { @@ -579,6 +1185,20 @@ class EnrollmentAdminController extends BaseController return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null; } + private function openFlagCount(string $schoolYear): int + { + if (! $this->db->tableExists('enrollment_flags')) { + return 0; + } + + $builder = $this->db->table('enrollment_flags')->where('status', 'open'); + if ($schoolYear !== '') { + $builder->where('school_year', $schoolYear); + } + + return $builder->countAllResults(); + } + private function flagTypes(): array { if (! $this->db->tableExists('enrollment_flags')) { diff --git a/app/Controllers/View/FilesController.php b/app/Controllers/View/FilesController.php index 2cb291b..baee46b 100644 --- a/app/Controllers/View/FilesController.php +++ b/app/Controllers/View/FilesController.php @@ -152,6 +152,10 @@ class FilesController extends Controller throw PageNotFoundException::forPageNotFound(); } + if (! $this->canViewEarlyDismissalSignature($name)) { + return $this->response->setStatusCode(403, 'You are not allowed to access this file.'); + } + // 3) Build path under writable (EARLY DISMISSAL SIGNATURES) $path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name; if (!is_file($path)) { @@ -200,7 +204,7 @@ class FilesController extends Controller ->setHeader('Content-Length', (string) $size) ->setHeader('ETag', $etag) ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT') - ->setHeader('Cache-Control', 'public, max-age=86400') + ->setHeader('Cache-Control', 'private, no-store') ->setBody(file_get_contents($path)); } @@ -433,6 +437,35 @@ class FilesController extends Controller return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester; } + private function canViewEarlyDismissalSignature(string $name): bool + { + $userId = (int) (session()->get('user_id') ?? 0); + if ($userId <= 0) { + return false; + } + + $roles = array_map('strtolower', (array) (session()->get('roles') ?? [])); + $activeRole = strtolower((string) (session()->get('role') ?? '')); + if ($activeRole !== '' && ! in_array($activeRole, $roles, true)) { + $roles[] = $activeRole; + } + + foreach (['administrator', 'administrative staff', 'principal', 'admin', 'teacher', 'teacher_assistant'] as $role) { + if (in_array($role, $roles, true)) { + return true; + } + } + + $row = \Config\Database::connect() + ->table('early_dismissal_signatures') + ->select('uploaded_by') + ->where('filename', $name) + ->get() + ->getRowArray(); + + return $row !== null && (int) ($row['uploaded_by'] ?? 0) === $userId; + } + private function expenseRecordForFile(string $name): ?array { return \Config\Database::connect() diff --git a/app/Controllers/View/HealthController.php b/app/Controllers/View/HealthController.php index 675a696..a8578b8 100644 --- a/app/Controllers/View/HealthController.php +++ b/app/Controllers/View/HealthController.php @@ -10,7 +10,6 @@ class HealthController extends Controller { return [ 'label' => $label, - 'path' => $path, 'exists' => is_dir($path), 'writable' => is_writable($path), ]; @@ -65,7 +64,6 @@ class HealthController extends Controller 'ok' => $ok, 'paths' => $pathsStatus, 'database' => $dbChecks, - 'write_path' => WRITEPATH, 'timestamp' => date('c'), ]; diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index cba2277..0656579 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -22,6 +22,7 @@ use App\Libraries\FinancialStatus; use App\Libraries\IssueInvoiceCommand; use App\Libraries\InvoiceIssuanceService; use App\Libraries\InvoiceLedgerService; +use App\Libraries\Tuition\GradeLevelParser; use DateTime; use DateTimeZone; @@ -44,7 +45,6 @@ class InvoiceController extends ResourceController protected $studentClassModel; protected $firstStudentFee; protected $secondStudentFee; - protected $youthFee; protected $refundDeadline; protected $invoiceEventModel; protected $paymentModel; @@ -83,9 +83,8 @@ class InvoiceController extends ResourceController $this->semester = $this->configModel->getConfig('semester'); $this->dueDate = $this->configModel->getConfig('first_day_of_school') ?: $this->configModel->getConfig('due_date'); - $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350); - $this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200); - $this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200); + $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380); + $this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280); $this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline'))); } @@ -676,36 +675,16 @@ class InvoiceController extends ResourceController } unset($student); // break reference - // 2) Partition into regular (<= grade 9) vs youth (> grade 9) - $regularCount = 0; - $youthCount = 0; + // 2) First student pays the base fee; every additional student pays base minus $100. + usort($students, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null)); - foreach ($students as $student) { - $levelInfo = $this->getGradeLevel($student['grade']); - $level = (int) ($levelInfo['level'] ?? 999); - - // Youth if level > $this->gradeFee (e.g., gradeFee = 9) - if ($level > $this->gradeFee) { - $youthCount++; - } else { - $regularCount++; - } - } - - // 3) Calculate totals per your rules + $studentCount = 0; $total = 0.0; - // Youth: flat youth fee per student - $total += $youthCount * $this->youthFee; - - // Regulars: first student full price, others discounted — but only if 2+ regulars - if ($regularCount >= 2) { - $total += $this->firstStudentFee; // one full - $total += ($regularCount - 1) * $this->secondStudentFee; // rest discounted - } elseif ($regularCount === 1) { - $total += $this->firstStudentFee; // single regular: no discount even if there are youths + foreach ($students as $student) { + $total += ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee; + $studentCount++; } - // if 0 regulars, nothing to add here return $total; } @@ -860,41 +839,27 @@ class InvoiceController extends ResourceController $refundAllowed = $currentDate <= $deadline; $studentCharges = []; - $regularCount = 0; + $studentCount = 0; /** - * Computes the fee for a student given numeric level and original grade name. - * - KG/K/Kindergarten are always treated as "regular". - * - Otherwise, "regular" means gradeLevel <= $this->gradeFee threshold. + * First student pays the base fee; every additional student pays base minus $100. */ - $computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount) { - $threshold = (int) $this->gradeFee; - - // Force Kindergarten to be regular regardless of numeric mapping - $isRegular = $this->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $threshold); - - if ($isRegular) { - $fee = ($regularCount === 0) ? $this->firstStudentFee : $this->secondStudentFee; - $regularCount++; - return $fee; - } - return $this->youthFee; + $computeCharge = function (array $student) use (&$studentCount) { + $fee = ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee; + $studentCount++; + return $fee; }; // Registered kids -> pay unit fee foreach ($registeredKids as $student) { - $gradeName = (string)$student['grade']; - $gradeLevel = $this->gradeLevelInt($gradeName); - $unitFee = $computeCharge($gradeLevel, $gradeName); + $unitFee = $computeCharge($student); $studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0]; } // Refund NOT allowed -> withdrawn students still owe unit fee if (!$refundAllowed) { foreach ($withdrawnKids as $student) { - $gradeName = (string)$student['grade']; - $gradeLevel = $this->gradeLevelInt($gradeName); - $unitFee = $computeCharge($gradeLevel, $gradeName); + $unitFee = $computeCharge($student); $studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0]; } } @@ -1625,6 +1590,23 @@ private function getGradeLevel($grade): array // View: Get invoices by parent ID (for web views) public function getByParent($parentId) { + $parentId = (int) $parentId; + $userId = (int) (session()->get('user_id') ?? 0); + $roles = array_map( + static fn ($role): string => strtolower(trim((string) $role)), + array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) + ); + $isStaff = (bool) array_intersect($roles, [ + 'administrator', + 'administrative staff', + 'principal', + 'admin', + ]); + + if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) { + return redirect()->to('/access_denied'); + } + $invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear); return view('invoice_list', ['invoices' => $invoices]); } diff --git a/app/Controllers/View/MessagesController.php b/app/Controllers/View/MessagesController.php index 4849191..f3ca734 100644 --- a/app/Controllers/View/MessagesController.php +++ b/app/Controllers/View/MessagesController.php @@ -92,6 +92,10 @@ class MessagesController extends BaseController $userRoleModel = new UserRoleModel(); //$role = session()->get('role'); $userId = session()->get('user_id'); + if (empty($userId)) { + return redirect()->to('/login'); + } + // Fetch the user role from the user_roles and roles tables $role = $userRoleModel->select('roles.name') ->join('roles', 'roles.id = user_roles.role_id') @@ -103,6 +107,11 @@ class MessagesController extends BaseController $attachmentPath = null; $file = $this->request->getFile('attachment'); if ($file && $file->isValid() && !$file->hasMoved()) { + $allowedExt = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx']; + $ext = strtolower((string) $file->getExtension()); + if (! in_array($ext, $allowedExt, true) || $file->getSize() > 5 * 1024 * 1024) { + return redirect()->back()->with('error', 'Attachment must be a document or image under 5MB.'); + } $attachmentPath = $file->store(); } diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 4217445..820b83f 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -92,15 +92,6 @@ class ParentController extends BaseController $this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0; helper(['url', 'form']); - - if (!session()->get('is_logged_in')) { - return redirect()->to('/login'); - } - - // Add more role-specific checks if needed - if (session()->get('role') !== 'parent') { - return redirect()->to('/access_denied'); - } } public function index() @@ -252,9 +243,6 @@ class ParentController extends BaseController public function enrollClasses() { try { - // Log session data for debugging - log_message('info', 'Session Data: ' . print_r(session()->get(), true)); - // Get deadlines and school year from config if (!$this->schoolYear) { log_message('error', 'Current school year not found in configuration.'); @@ -373,7 +361,7 @@ class ParentController extends BaseController if ($isEditable) { $student['transition_evaluation'] = $previousSchoolYear !== null - ? $this->transitionEvaluationForStudent((int) $studentId, $previousSchoolYear, $selectedYear) + ? $this->transitionEvaluationForStudent((int) $parentId, (int) $studentId, $previousSchoolYear, $selectedYear) : null; $student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition( $student, @@ -382,11 +370,13 @@ class ParentController extends BaseController ); $student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']); $student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']); + $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); } else { $student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']); $student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info']; $student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned'); $student['required_action_label'] = 'Read-only closed school year.'; + $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); } } @@ -416,8 +406,6 @@ class ParentController extends BaseController public function enrollClassesHandler() { - // Call enrollClasses() function at the start of this method - $this->enrollClasses(); $refundService = new FeeCalculationService(); // Retrieve enrollment and withdrawal data from the POST request @@ -460,25 +448,70 @@ class ParentController extends BaseController if (!empty($enroll)) { $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - $blockingDecisionMessages = $this->blockedEnrollmentDecisionMessages(array_map('intval', (array) $enroll), $selectedYear); - if ($blockingDecisionMessages !== []) { - return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages)); + $previousSchoolYear = $this->previousSchoolYearName($selectedYear); + if ($previousSchoolYear === null) { + return redirect()->back()->withInput()->with('error', 'Enrollment cannot be submitted because the closing school year could not be determined.'); } - $financialBlockers = $this->financialSubmissionBlockers((int) $parentId, $selectedYear); - if ($financialBlockers !== []) { - return redirect()->back()->withInput()->with('error', implode(' ', $financialBlockers)); + $parent = $this->userModel->find((int) $parentId); + if (! is_array($parent) || ($parent['user_type'] ?? '') !== 'primary') { + return redirect()->back()->withInput()->with('error', 'Only primary parents can enroll students.'); } + $transitionService = service('enrollmentTransition'); + $submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0))); + $evaluations = []; + $errors = []; + + foreach ($submittedStudentIds as $studentId) { + try { + $evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent'); + } catch (Throwable $e) { + log_message('error', 'Parent enrollment eligibility evaluation failed for student {studentId}: {message}', [ + 'studentId' => $studentId, + 'message' => $e->getMessage(), + ]); + $errors[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact administration.'; + continue; + } + + $studentInfo = $this->studentModel->find($studentId); + if (! is_array($studentInfo)) { + $errors[] = 'Student ID ' . $studentId . ': student record was not found.'; + continue; + } + + $studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId; + if (empty($evaluation['can_enroll'])) { + $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) . ')' : '') . '.'); + continue; + } + + if (! $this->studentModel->getStudentSchoolIdByStudentId($studentId)) { + $errors[] = $studentName . ': Student school ID not found.'; + continue; + } + + $evaluations[$studentId] = $evaluation; + } + + if ($errors !== []) { + return redirect()->back()->withInput()->with('error', implode(' ', $errors)); + } + + $this->db->transStart(); foreach ($enroll as $studentId) { + $studentId = (int) $studentId; + if (! isset($evaluations[$studentId])) { + continue; + } + + $evaluation = $evaluations[$studentId]; // Get student full name (supports both string return or array with firstname/lastname) $studentInfo = $this->studentModel->getFullNameById($studentId); - if (empty($studentName)) { - $studentName = "Student ID $studentId"; - log_message('warning', "Name for student ID $studentId not found in students table."); - } - // Save student info into $studentData $studentData[$studentId] = $studentInfo; // raw return from getFullName() @@ -490,49 +523,57 @@ class ParentController extends BaseController ->get() ->getRowArray(); - $studentSchoolId = $this->studentModel->getStudentSchoolIdByStudentId($studentId); - if (!$studentSchoolId) { - return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found."); - } + $studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId; if ($existingEnrollment) { $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; + $update = $this->enrollmentPayloadFromEvaluation($evaluation, [ + 'is_withdrawn' => 0, + 'withdrawal_date' => null, + 'enrollment_status' => $targetEnrollmentStatus, + 'admission_status' => $targetAdmissionStatus, + 'updated_at' => utc_now(), + ]); + if ($existingEnrollment['is_withdrawn'] == 1) { // Reactivate the enrollment if the student was previously withdrawn - $this->enrollmentModel->where('id', $existingEnrollment['id'])->update([ - 'is_withdrawn' => 0, - 'withdrawal_date' => null, - 'enrollment_status' => $targetEnrollmentStatus, - 'admission_status' => $targetAdmissionStatus, - 'updated_at' => utc_now() - ]); + $this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); // Apply promotion-based class placement for the upcoming year $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } else { $currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? ''); - $update = [ - 'updated_at' => utc_now(), - ]; if ($currentStatus === 'enrolled') { $update['admission_status'] = 'accepted'; - } else { - $update['enrollment_status'] = $targetEnrollmentStatus; - $update['admission_status'] = $targetAdmissionStatus; } $this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } + + $enrollmentId = (int) $existingEnrollment['id']; + if (! empty($evaluation['admin_exception']['id'])) { + $transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId); + } + $transitionService->auditEnrollmentDecision( + $studentId, + $selectedYear, + $previousSchoolYear, + ! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted', + (int) $parentId, + $existingEnrollment, + $this->enrollmentAuditPayload($update, $evaluation), + implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])) + ); } else { $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; // If no enrollment record exists, insert a new enrollment record - $result = $this->enrollmentModel->insert([ + $payload = $this->enrollmentPayloadFromEvaluation($evaluation, [ 'student_id' => $studentId, 'parent_id' => $parentId, 'school_year' => $selectedYear, @@ -543,16 +584,38 @@ class ParentController extends BaseController 'admission_status' => $targetAdmissionStatus, 'created_at' => utc_now() ]); + $result = $this->enrollmentModel->insert($payload, true); if (!$result) { - dd($this->enrollmentModel->errors()); + $this->db->transRollback(); + return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.'); } else { log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled."); // Apply promotion-based class placement for the upcoming year $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } + + $enrollmentId = (int) $result; + if (! empty($evaluation['admin_exception']['id'])) { + $transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId); + } + $transitionService->auditEnrollmentDecision( + $studentId, + $selectedYear, + $previousSchoolYear, + ! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted', + (int) $parentId, + null, + $this->enrollmentAuditPayload($payload, $evaluation), + implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])) + ); } } + $this->db->transComplete(); + + if (! $this->db->transStatus()) { + return redirect()->back()->withInput()->with('error', 'A database error occurred while submitting enrollment.'); + } } // $studentData now holds info for all students processed @@ -652,6 +715,74 @@ class ParentController extends BaseController } } + private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array + { + $payload = array_merge($base, [ + 'source_school_year' => $evaluation['source_school_year'] ?? null, + 'deliberation_decision' => $evaluation['deliberation_decision'] ?? null, + 'source_grade_id' => $evaluation['source_grade_id'] ?? null, + 'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null, + 'source_class_section_id' => $evaluation['source_class_section_id'] ?? null, + 'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null, + 'class_section_id' => $evaluation['assigned_class_section_id'] ?? ($base['class_section_id'] ?? null), + 'placement_status' => $evaluation['placement_status'] ?? null, + 'age_reference_date' => $evaluation['age_reference_date'] ?? null, + 'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null, + 'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0, + 'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0, + 'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0, + 'exception_required' => ! empty($evaluation['admin_exception']) || ! empty($evaluation['flags']) ? 1 : 0, + 'exception_reason' => $this->exceptionReasonFromEvaluation($evaluation), + 'registration_submitted_at' => utc_now(), + ]); + + return $this->filterEnrollmentPayloadByColumns($payload); + } + + private function exceptionReasonFromEvaluation(array $evaluation): ?string + { + if (! empty($evaluation['admin_exception'])) { + return 'Admin exception: ' . (string) ($evaluation['admin_exception']['reason_code'] ?? 'approved'); + } + + $codes = array_values(array_filter(array_map('strval', array_merge( + $evaluation['blocking_rule_codes'] ?? [], + $evaluation['review_rule_codes'] ?? [], + $evaluation['warning_rule_codes'] ?? [] + )))); + + return $codes !== [] ? implode(', ', array_unique($codes)) : null; + } + + private function enrollmentAuditPayload(array $payload, array $evaluation): array + { + return [ + 'enrollment' => $payload, + 'eligibility' => [ + 'decision' => $evaluation['decision'] ?? null, + 'can_enroll' => ! empty($evaluation['can_enroll']), + 'rule_codes' => $evaluation['rule_codes'] ?? [], + 'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [], + 'review_rule_codes' => $evaluation['review_rule_codes'] ?? [], + 'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [], + 'admin_exception' => $evaluation['admin_exception'] ?? null, + 'financial_summary' => $evaluation['financial_summary'] ?? null, + 'last_name_exception_carry_forward' => $evaluation['last_name_exception_carry_forward'] ?? null, + ], + ]; + } + + private function filterEnrollmentPayloadByColumns(array $payload): array + { + foreach (array_keys($payload) as $column) { + if (! $this->db->fieldExists($column, 'enrollments')) { + unset($payload[$column]); + } + } + + return $payload; + } + private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool { if ($parentId <= 0 || $schoolYear === '') { @@ -1243,7 +1374,8 @@ class ParentController extends BaseController try { $studentName = $this->studentNameForEnrollmentMessage($studentId); - $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $targetSchoolYear, 'parent'); + $parentId = (int) session()->get('user_id'); + $evaluation = service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent'); foreach ($evaluation['blockers'] ?? [] as $blocker) { $blocker = trim((string) $blocker); if ($blocker !== '') { @@ -1268,10 +1400,10 @@ class ParentController extends BaseController return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn); } - private function transitionEvaluationForStudent(int $studentId, string $previousSchoolYear, string $selectedYear): ?array + private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array { try { - return service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $selectedYear, 'parent'); + return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent'); } catch (\Throwable $e) { log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage()); @@ -1317,6 +1449,14 @@ class ParentController extends BaseController ); } + if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) { + return [ + 'message' => 'Enrollment has been authorized by administration.', + 'blocking' => false, + 'level' => 'info', + ]; + } + $blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? [])))); if ($blockers !== []) { $name = $this->studentNameFromRow($student); @@ -1409,31 +1549,80 @@ class ParentController extends BaseController private function requiredActionLabel(?array $evaluation): string { if ($evaluation === null) { - return 'Complete re-enrollment before the registration deadline.'; + return 'Contact administration'; } - if (($evaluation['blockers'] ?? []) !== []) { - if (($evaluation['adult_student'] ?? false) && ! ($evaluation['parent_enrollment_allowed'] ?? false)) { - return 'Student must complete the authorized adult-student process or contact administration.'; + $state = $this->parentEnrollmentStateFromEvaluation($evaluation, null); + return match ($state) { + 'Enroll' => 'Complete re-enrollment before the registration deadline.', + 'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.', + 'Already submitted' => 'Already submitted', + 'Action needed' => 'Action needed: pay the previous-year balance or contact administration.', + 'Under review' => 'Under review. Contact the school administration.', + default => 'Contact administration', + }; + } + + 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)) { + return 'Already submitted'; + } + + return $this->parentEnrollmentStateFromEvaluation( + is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null, + $status + ); + } + + private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string + { + if ($evaluation === null) { + return 'Contact administration'; + } + + $decision = (string) ($evaluation['decision'] ?? ''); + $codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []); + + if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') { + return 'Already submitted'; + } + + if (! empty($evaluation['can_enroll']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') { + if ($decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []) { + return 'Eligible with follow-up'; } - - $decision = (string) ($evaluation['deliberation_decision'] ?? ''); - if (in_array($decision, [ - DeliberationDecision::EXPELLED, - DeliberationDecision::WITHDRAWN, - DeliberationDecision::DEFERRED_DECISION, - ], true)) { - return 'Contact the school administration.'; - } - - return 'Review the eligibility message above.'; + return 'Enroll'; } - if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) { - return 'Complete re-enrollment and follow make-up exam instructions.'; + if ($decision === 'ELIGIBLE_WITH_WARNING') { + return 'Eligible with follow-up'; } - return 'Complete re-enrollment before the registration deadline.'; + if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true) + || in_array('FINANCE_APPROVAL_REQUIRED', $codes, true) + || in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true) + ) { + return 'Action needed'; + } + + if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [ + DeliberationDecision::EXPELLED, + DeliberationDecision::WITHDRAWN, + DeliberationDecision::DEFERRED_DECISION, + ], true)) { + return 'Under review'; + } + + return 'Contact administration'; } private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array @@ -1444,7 +1633,7 @@ class ParentController extends BaseController $registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2); $tuitionDue = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2); $mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2); - $behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'information_only'); + $behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'); $amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees; return [ @@ -1461,21 +1650,6 @@ class ParentController extends BaseController ]; } - private function financialSubmissionBlockers(int $parentId, string $selectedYear): array - { - $previousSchoolYear = $this->previousSchoolYearName($selectedYear); - $summary = $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear); - if (($summary['carry_over_balance'] ?? 0.0) <= 0.0) { - return []; - } - - return match ((string) ($summary['balance_behavior'] ?? 'information_only')) { - 'submission_blocked_until_payment' => ['Registration cannot be submitted until the previous-year balance is paid.'], - 'admin_approval_required' => ['Registration requires administrative financial approval because there is a previous-year balance.'], - default => [], - }; - } - private function financialPolicyMessage(string $behavior, string $configured): string { $configured = trim($configured); @@ -1488,7 +1662,7 @@ class ParentController extends BaseController 'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.', 'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.', 'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.', - default => 'The balance is shown for information and does not currently block registration.', + default => 'The previous-year balance must be paid before registration can be submitted.', }; } @@ -1784,6 +1958,10 @@ class ParentController extends BaseController public function profile($id) { + if (! $this->canAccessUserRecord((int) $id)) { + return redirect()->to('/access_denied'); + } + // Fetch the user's data based on the given ID $user = $this->userModel->find($id); @@ -1798,6 +1976,10 @@ class ParentController extends BaseController public function updateProfile($id) { + if (! $this->canAccessUserRecord((int) $id)) { + return redirect()->to('/access_denied'); + } + $user = $this->userModel->find($id); // Step 1: Check if user exists @@ -2937,4 +3119,22 @@ $existing = $this->studentModel return redirect()->back()->with('success', 'Participation updated'); } + + private function canAccessUserRecord(int $id): bool + { + $userId = (int) (session()->get('user_id') ?? 0); + if ($userId <= 0 || $id <= 0) { + return false; + } + if ($userId === $id) { + return true; + } + + $roles = array_map( + static fn ($role): string => strtolower(trim((string) $role)), + array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) + ); + + return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']); + } } diff --git a/app/Controllers/View/ParentFinancialAidController.php b/app/Controllers/View/ParentFinancialAidController.php new file mode 100644 index 0000000..02ed22a --- /dev/null +++ b/app/Controllers/View/ParentFinancialAidController.php @@ -0,0 +1,84 @@ +get('user_id'); + if ($parentId <= 0) { + return redirect()->to('/login'); + } + + $schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? ''); + $model = new FinancialAidRequestModel(); + $requests = $model + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->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), + ]); + } + + public function submit() + { + $parentId = (int) session()->get('user_id'); + if ($parentId <= 0) { + return redirect()->to('/login'); + } + + $schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? ''); + $model = new FinancialAidRequestModel(); + if ($model->openRequestForParent($parentId, $schoolYear) !== null) { + return redirect()->back()->with('error', 'You already have an open financial aid request for this school year.'); + } + + $studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids'))))); + $linkedIds = 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.'); + } + + $needStatement = trim((string) $this->request->getPost('need_statement')); + if ($needStatement === '') { + 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, + 'need_statement' => $needStatement, + 'requested_amount' => $requestedAmount !== '' ? (float) $requestedAmount : null, + 'status' => 'submitted', + ]); + + return redirect()->to('/parent/financial-aid')->with('success', 'Your financial aid request was submitted.'); + } +} diff --git a/app/Controllers/View/PreferencesController.php b/app/Controllers/View/PreferencesController.php index a1796bf..1732615 100644 --- a/app/Controllers/View/PreferencesController.php +++ b/app/Controllers/View/PreferencesController.php @@ -20,13 +20,16 @@ class PreferencesController extends BaseController */ public function index($userId = null) { - // Get user ID from parameter or session - $userId = $userId ?? (int) session()->get('user_id'); - - if (!$userId) { + $sessionUserId = (int) (session()->get('user_id') ?? 0); + if ($sessionUserId <= 0) { return redirect()->to('/login')->with('error', 'Please log in to view preferences'); } + $userId = (int) ($userId ?? $sessionUserId); + if (! $this->canAccessUserPreferences($userId, $sessionUserId)) { + return redirect()->to('/access_denied'); + } + // Fetch preferences for the current user $preferences = $this->preferencesModel->where('user_id', $userId)->first(); @@ -64,13 +67,16 @@ class PreferencesController extends BaseController */ public function updatePreferences($userId = null) { - // Get user ID from parameter or session - $userId = $userId ?? (int) session()->get('user_id'); - - if (!$userId) { + $sessionUserId = (int) (session()->get('user_id') ?? 0); + if ($sessionUserId <= 0) { return redirect()->to('/login')->with('error', 'Please log in to update preferences'); } + $userId = (int) ($userId ?? $sessionUserId); + if (! $this->canAccessUserPreferences($userId, $sessionUserId)) { + return redirect()->to('/access_denied'); + } + // Validation rules $validation = \Config\Services::validation(); @@ -141,4 +147,21 @@ class PreferencesController extends BaseController // Redirect back to preferences page with success message return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully'); } + + private function canAccessUserPreferences(int $requestedUserId, int $sessionUserId): bool + { + if ($requestedUserId <= 0 || $sessionUserId <= 0) { + return false; + } + if ($requestedUserId === $sessionUserId) { + return true; + } + + $roles = array_map( + static fn ($role): string => strtolower(trim((string) $role)), + array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) + ); + + return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']); + } } diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index 81b5e92..b5cb809 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -914,28 +914,37 @@ class StudentController extends BaseController } $total = count($cands); - if ($sectionCount * $minPerSection > $total) { - $msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.'; - return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); - } - if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) { - $msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.'; - return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); - } - // Fetch lettered sections for this class + $baseSection = $this->sectionForDistribution($classSectionId, $year); + + // Fetch lettered sections for this class. The requested section count is a max: + // if the class cannot split into 2+ sections, keep the assignment on the base grade. $letters = $this->letterSectionsForDistribution($classId, $year); - if (empty($letters)) { - $msg = 'No lettered sections found for the selected class.'; + $availableSectionCount = count($letters); + if (!$baseSection || (int)($baseSection['class_id'] ?? 0) !== $classId) { + $msg = 'No base grade found for the selected class.'; return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); } - if (count($letters) < $sectionCount) { - $msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters); - return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); + $actualSectionCount = min($sectionCount, $availableSectionCount); + if ($minPerSection > 0) { + $actualSectionCount = min($actualSectionCount, max(1, intdiv($total, $minPerSection))); + } + if ($maxPerSection !== null) { + $minimumNeededForCapacity = (int)ceil($total / $maxPerSection); + $capacitySectionCount = max(1, $availableSectionCount); + if ($minimumNeededForCapacity > $capacitySectionCount || $minimumNeededForCapacity > $sectionCount) { + $msg = 'Capacity exceeded: available sections can hold at most ' . ($capacitySectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.'; + return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); + } + $actualSectionCount = max($actualSectionCount, $minimumNeededForCapacity); } - $letters = array_slice($letters, 0, $sectionCount); + if ($actualSectionCount < 2) { + $letters = [$baseSection]; + } else { + $letters = array_slice($letters, 0, $actualSectionCount); + } $buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection); $draftModel = new StudentSectionDistributionDraftModel(); @@ -946,21 +955,11 @@ class StudentController extends BaseController $draftIdByStudentId = []; $this->db->transStart(); - $studentIdsToReplace = array_values(array_unique(array_map( - static fn(array $student): int => (int)($student['student_id'] ?? 0), - $cands - ))); - if (!empty($studentIdsToReplace)) { - $draftModel->where('school_year', $year) - ->whereIn('student_id', $studentIdsToReplace) - ->where('status', 'pending') - ->delete(); - } foreach ($buckets as $b) { $secId = (int)$b['class_section_id']; foreach ($b['assigned'] as $student) { $sid = (int)$student['student_id']; - $draftId = (int)$draftModel->insert([ + $draftId = $this->upsertDistributionDraft($draftModel, [ 'student_id' => $sid, 'class_id' => $classId, 'class_section_id' => $secId, @@ -971,7 +970,6 @@ class StudentController extends BaseController 'status' => 'pending', 'batch_key' => $batchKey, 'created_by' => $updatedBy, - 'created_at' => $now, 'updated_at' => $now, ]); if ($draftId > 0) { @@ -1026,6 +1024,7 @@ class StudentController extends BaseController 'age_at_reference' => $student['age_at_reference'] ?? null, 'gender' => (string)($student['gender'] ?? ''), 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), + 'previous_final_score' => $student['previous_final_score'] ?? null, 'class_id' => $classId, 'class_section_id' => $secId, 'class_section_name' => $nameById[$secId] ?? (string)$secId, @@ -1182,12 +1181,7 @@ class StudentController extends BaseController $now = utc_now(); $this->db->transStart(); - $draftModel->where('student_id', $studentId) - ->where('school_year', $year) - ->where('status', 'pending') - ->delete(); - - $draftId = (int)$draftModel->insert([ + $draftId = $this->upsertDistributionDraft($draftModel, [ 'student_id' => $studentId, 'class_id' => $targetClassId, 'class_section_id' => $targetSectionId, @@ -1198,7 +1192,6 @@ class StudentController extends BaseController 'status' => 'pending', 'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)), 'created_by' => $updatedBy, - 'created_at' => $now, 'updated_at' => $now, ]); @@ -1286,7 +1279,8 @@ class StudentController extends BaseController return $this->mergeDistributionCandidates( $out, $this->decisionDistributionCandidates($classId, $year), - $this->currentYearDistributionCandidates($classId, $year) + $this->currentYearDistributionCandidates($classId, $year), + $this->registeredKgDistributionCandidates($classId, $year) ); } @@ -1302,7 +1296,7 @@ class StudentController extends BaseController $builder = $this->db->table('student_class sc') ->select('0 AS promotion_queue_id, sc.student_id, sc.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) ->join('students', 'students.id = sc.student_id', 'inner') - ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left') ->where('sc.school_year', $year) ->where('sc.class_section_id IS NOT NULL', null, false); @@ -1325,7 +1319,7 @@ class StudentController extends BaseController $builder = $this->db->table('enrollments e') ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) ->join('students', 'students.id = e.student_id', 'inner') - ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.school_year', $year) ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupStart() @@ -1413,6 +1407,40 @@ class StudentController extends BaseController return $out; } + private function upsertDistributionDraft(StudentSectionDistributionDraftModel $draftModel, array $data): int + { + $studentId = (int)($data['student_id'] ?? 0); + $year = (string)($data['school_year'] ?? ''); + if ($studentId <= 0 || $year === '') { + return 0; + } + + $existing = $draftModel + ->where('student_id', $studentId) + ->where('school_year', $year) + ->first(); + + if ($existing) { + $draftId = (int)($existing['id'] ?? 0); + if ($draftId <= 0) { + return 0; + } + + unset($data['created_at']); + $data['status'] = 'pending'; + $data['applied_at'] = null; + $draftModel->update($draftId, $data); + + return $draftId; + } + + if (empty($data['created_at'])) { + $data['created_at'] = $data['updated_at'] ?? utc_now(); + } + + return (int)$draftModel->insert($data); + } + private function kgDistributionCandidates(int $classId, string $year): array { if ($classId <= 0 || $year === '' || ! $this->db->tableExists('enrollments') || ! $this->db->tableExists('students')) { @@ -1422,7 +1450,7 @@ class StudentController extends BaseController $builder = $this->db->table('enrollments e') ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) ->join('students', 'students.id = e.student_id', 'inner') - ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.school_year', $year) ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupStart() @@ -1515,7 +1543,12 @@ class StudentController extends BaseController if ($this->db->fieldExists('school_year', 'students')) { $builder->where('school_year', $year); } elseif ($this->db->fieldExists('year_of_registration', 'students') && preg_match('/^(\d{4})/', $year, $matches)) { - $builder->where('year_of_registration', (int)$matches[1]); + $registrationYears = [(int)$matches[1]]; + $previousYear = $this->previousSchoolYearName($year); + if ($previousYear !== null && preg_match('/^(\d{4})/', $previousYear, $previousMatches)) { + $registrationYears[] = (int)$previousMatches[1]; + } + $builder->whereIn('year_of_registration', array_values(array_unique($registrationYears))); } if ($this->db->fieldExists('is_active', 'students')) { @@ -1537,6 +1570,10 @@ class StudentController extends BaseController continue; } + if (!$this->studentHasOnlyKgPriorPlacement($studentId, $year)) { + continue; + } + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); $targetClassId = $this->distributionTargetClassIdForStudent( $classId, @@ -1565,6 +1602,72 @@ class StudentController extends BaseController return $out; } + private function studentHasOnlyKgPriorPlacement(int $studentId, string $targetSchoolYear): bool + { + $previousYear = $this->previousSchoolYearName($targetSchoolYear); + if ($studentId <= 0 || $previousYear === null) { + return true; + } + + $baseNames = []; + if ($this->db->tableExists('student_class')) { + $builder = $this->db->table('student_class sc') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $previousYear) + ->where('sc.class_section_id IS NOT NULL', null, false); + + if ($this->db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + + foreach ($builder->get()->getResultArray() as $row) { + $baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? '')); + if ($baseName !== '') { + $baseNames[$baseName] = true; + } + } + } + + if ($this->db->tableExists('enrollments')) { + $rows = $this->db->table('enrollments e') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') + ->where('e.student_id', $studentId) + ->where('e.school_year', $previousYear) + ->where('e.class_section_id IS NOT NULL', null, false) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? '')); + if ($baseName !== '') { + $baseNames[$baseName] = true; + } + } + } + + if (empty($baseNames)) { + return true; + } + + return count($baseNames) === 1 && isset($baseNames['KG']); + } + + private function baseClassNameForDistribution(string $classSectionName): string + { + return strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? '')); + } + private function isDistributionKgClass(int $classId, string $year): bool { if ($classId <= 0) { @@ -1722,7 +1825,8 @@ class StudentController extends BaseController $targetClassId = $this->targetClassIdFromDecision( (string)($row['class_section_name'] ?? ''), - (string)($row['decision'] ?? '') + (string)($row['decision'] ?? ''), + $targetSchoolYear ); $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear); $targetClassId = $this->distributionTargetClassIdForStudent( @@ -1754,7 +1858,7 @@ class StudentController extends BaseController return $out; } - private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int + private function targetClassIdFromDecision(string $classSectionName, string $decision, string $targetSchoolYear = ''): ?int { $baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? '')); if ($baseName === '') { @@ -1767,17 +1871,30 @@ class StudentController extends BaseController $targetBaseName = '1'; } elseif (ctype_digit($baseName)) { $level = (int)$baseName; - $targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1); + $targetBaseName = $level >= 10 ? 'YOUTH' : (string)($level + 1); } elseif ($baseName === 'YOUTH') { $targetBaseName = 'YOUTH'; } } - $row = $this->classSectionModel + $query = $this->classSectionModel ->select('class_id') ->where('UPPER(class_section_name)', $targetBaseName) ->where("class_section_name NOT LIKE '%-%'", null, false) - ->first(); + ->orderBy('id', 'DESC'); + if ($targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $targetSchoolYear); + } + + $row = $query->first(); + + if (!$row && $targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->classSectionModel + ->select('class_id') + ->where('UPPER(class_section_name)', $targetBaseName) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->first(); + } return $row ? (int)$row['class_id'] : null; } @@ -1884,7 +2001,7 @@ class StudentController extends BaseController if ($this->db->tableExists('student_class')) { $builder = $this->db->table('student_class sc') ->select('cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left') ->where('sc.student_id', $studentId) ->where('sc.school_year', $previousYear) ->where('sc.class_section_id IS NOT NULL', null, false); @@ -1911,7 +2028,7 @@ class StudentController extends BaseController if (empty($names) && $this->db->tableExists('enrollments')) { $rows = $this->db->table('enrollments e') ->select('cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left') ->where('e.student_id', $studentId) ->where('e.school_year', $previousYear) ->where('e.class_section_id IS NOT NULL', null, false) @@ -2236,6 +2353,7 @@ class StudentController extends BaseController 'age_at_reference' => $student['age_at_reference'] ?? null, 'gender' => (string)($student['gender'] ?? ''), 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), + 'previous_final_score' => $student['previous_final_score'] ?? null, 'class_id' => $classId, 'class_section_id' => 0, 'class_section_name' => $className, @@ -2277,7 +2395,26 @@ class StudentController extends BaseController } $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); - $reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); + $reference = null; + $configuredReference = ''; + try { + $configuredReference = trim((string)($this->configModel ? $this->configModel->getConfig('date_age_reference') : '')); + } catch (\Throwable $e) { + $configuredReference = ''; + } + + if ($configuredReference !== '') { + $candidate = \DateTimeImmutable::createFromFormat('!Y-m-d', $configuredReference, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + if ($candidate !== false && !$hasErrors && $candidate->format('Y') === $matches[1]) { + $reference = $candidate; + } + } + + if ($reference === null) { + $reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); + } return $reference->setTime(0, 0, 0); } @@ -2312,6 +2449,10 @@ class StudentController extends BaseController return $this->distributionBaseClassIdByName('KG', $schoolYear); } + if ($defaultClassId === null && $ageAtReference === 6) { + return $this->distributionBaseClassIdByName('1', $schoolYear); + } + if ($this->isDistributionKgSource($defaultClassId, $sourceClassName, $schoolYear)) { if ($ageAtReference !== null && $ageAtReference < 6) { return $this->distributionBaseClassIdByName('KG', $schoolYear) ?? $defaultClassId; @@ -2474,8 +2615,8 @@ class StudentController extends BaseController } $builder = $this->db->table('student_section_distribution_drafts d') - ->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id') - ->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left') + ->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, d.previous_final_score, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id') + ->join('classSection cs', 'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year', 'left') ->join('students', 'students.id = d.student_id', 'left') ->where('d.class_id', $classId) ->where('d.school_year', $year) @@ -2511,6 +2652,10 @@ class StudentController extends BaseController 'class_section_id' => $sectionId, 'class_section_name' => (string)($row['class_section_name'] ?? $sectionId), 'total' => 0, + 'male' => 0, + 'female' => 0, + 'score_total' => 0.0, + 'score_count' => 0, 'student_names' => [], 'student_assignments' => [], ]; @@ -2520,6 +2665,16 @@ class StudentController extends BaseController if ($name === '') { $name = 'Student #' . $studentId; } + $gender = strtolower((string)($row['gender'] ?? '')); + if ($gender === 'female') { + $sections[$sectionId]['female']++; + } else { + $sections[$sectionId]['male']++; + } + if (is_numeric($row['previous_final_score'] ?? null)) { + $sections[$sectionId]['score_total'] += (float)$row['previous_final_score']; + $sections[$sectionId]['score_count']++; + } $sections[$sectionId]['student_names'][] = $name; $sections[$sectionId]['student_assignments'][] = [ 'draft_id' => (int)($row['draft_id'] ?? 0), @@ -2527,6 +2682,7 @@ class StudentController extends BaseController 'student_name' => $name, 'age_at_reference' => $this->distributionAgeAtReference($row['dob'] ?? null, $year), 'gender' => (string)($row['gender'] ?? ''), + 'previous_final_score' => is_numeric($row['previous_final_score'] ?? null) ? (float)$row['previous_final_score'] : null, 'last_year_class_section' => $this->distributionPreviousClassSectionName( $studentId, $year, @@ -2538,6 +2694,15 @@ class StudentController extends BaseController $sections[$sectionId]['total']++; } + foreach ($sections as &$section) { + $scoreCount = (int)($section['score_count'] ?? 0); + $section['average_score'] = $scoreCount > 0 + ? round((float)$section['score_total'] / $scoreCount, 2) + : null; + unset($section['score_total'], $section['score_count']); + } + unset($section); + return array_values($sections); } diff --git a/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php b/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php index 5866679..ce4b025 100644 --- a/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php +++ b/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php @@ -73,7 +73,7 @@ class CreateWhatsappGroupMemberships extends Migration $this->forge->addKey(['class_section_id', 'school_year', 'semester']); $this->forge->addUniqueKey(['class_section_id', 'school_year', 'semester', 'subject_type', 'subject_id'], 'uniq_whatsapp_membership'); - $this->forge->createTable('whatsapp_group_memberships'); + $this->forge->createTable('whatsapp_group_memberships', true); } public function down() diff --git a/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php b/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php index f8463f6..a16f085 100644 --- a/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php +++ b/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php @@ -55,7 +55,7 @@ class CreateReimbursementBatchAdminFiles extends Migration $this->forge->addKey('batch_id'); $this->forge->addKey('admin_id'); $this->forge->addUniqueKey(['batch_id', 'admin_id']); - $this->forge->createTable('reimbursement_batch_admin_files'); + $this->forge->createTable('reimbursement_batch_admin_files', true); } public function down() diff --git a/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php b/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php index 23a3c05..b992186 100644 --- a/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php +++ b/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php @@ -68,7 +68,7 @@ class CreatePrintRequests extends Migration if ($this->db->tableExists('classes')) { $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); } - $this->forge->createTable('print_requests'); + $this->forge->createTable('print_requests', true); } public function down() diff --git a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php index d2b68c5..3637b95 100644 --- a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php +++ b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php @@ -30,6 +30,16 @@ class RevertPrintRequestsForeignKey extends Migration return; } + $orphanCount = (int) $db->table('print_requests pr') + ->join('classes c', 'c.id = pr.class_id', 'left') + ->where('pr.class_id IS NOT NULL', null, false) + ->where('c.id IS NULL', null, false) + ->countAllResults(); + + if ($orphanCount > 0) { + return; + } + // Add the new foreign key $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); diff --git a/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php b/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php index b5c2f08..a329234 100644 --- a/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php +++ b/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php @@ -29,6 +29,16 @@ class FixPrintRequestsForeignKeyOnceAndForAll extends Migration return; } + $orphanCount = (int) $db->table('print_requests pr') + ->join('classSection cs', 'cs.id = pr.class_id', 'left') + ->where('pr.class_id IS NOT NULL', null, false) + ->where('cs.id IS NULL', null, false) + ->countAllResults(); + + if ($orphanCount > 0) { + return; + } + $this->forge->addForeignKey('class_id', 'classSection', 'id', 'CASCADE', 'CASCADE'); $this->forge->processIndexes('print_requests'); } diff --git a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php index 1f6fe38..bac318d 100644 --- a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php +++ b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php @@ -20,6 +20,16 @@ class FixPrintRequestsForeignKeyAgain extends Migration } } + $orphanCount = (int) $db->table('print_requests pr') + ->join('classSection cs', 'cs.class_id = pr.class_id', 'left') + ->where('pr.class_id IS NOT NULL', null, false) + ->where('cs.class_id IS NULL', null, false) + ->countAllResults(); + + if ($orphanCount > 0) { + return; + } + $this->forge->addForeignKey('class_id', 'classSection', 'class_id', 'CASCADE', 'CASCADE'); $this->forge->processIndexes('print_requests'); } diff --git a/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php b/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php index 962c32f..3ea31a4 100644 --- a/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php +++ b/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php @@ -36,7 +36,7 @@ class CreateClassProgressReports extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['class_section_id', 'week_start', 'week_end']); - $this->forge->createTable('class_progress_reports'); + $this->forge->createTable('class_progress_reports', true); } public function down() diff --git a/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php b/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php index c54ae3c..329f043 100644 --- a/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php +++ b/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php @@ -54,7 +54,7 @@ class CreateSubjectCurriculumItems extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['class_id', 'subject']); - $this->forge->createTable('subject_curriculum_items'); + $this->forge->createTable('subject_curriculum_items', true); } public function down() diff --git a/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php b/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php index eef2e87..0f1cfcb 100644 --- a/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php +++ b/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php @@ -63,7 +63,7 @@ class CreateTeacherSubmissionNotificationHistory extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['teacher_id', 'class_section_id']); - $this->forge->createTable('teacher_submission_notification_history'); + $this->forge->createTable('teacher_submission_notification_history', true); } public function down() diff --git a/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php b/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php index 9039c57..15af03a 100644 --- a/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php +++ b/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php @@ -100,7 +100,7 @@ class CreateExamDraftSubmissions extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['teacher_id', 'class_section_id']); - $this->forge->createTable('exam_drafts'); + $this->forge->createTable('exam_drafts', true); } public function down() diff --git a/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php b/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php index 3ecb1ec..8c15342 100644 --- a/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php +++ b/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php @@ -8,19 +8,27 @@ class AddVersionToExamDrafts extends Migration { public function up() { - $this->forge->addColumn('exam_drafts', [ - 'version' => [ + $fields = []; + + if (! $this->db->fieldExists('version', 'exam_drafts')) { + $fields['version'] = [ 'type' => 'INT', 'unsigned' => true, 'default' => 1, - ], - 'previous_draft_id' => [ + ]; + } + + if (! $this->db->fieldExists('previous_draft_id', 'exam_drafts')) { + $fields['previous_draft_id'] = [ 'type' => 'INT', 'unsigned' => true, 'null' => true, - ], - ]); - $this->forge->addKey('version'); + ]; + } + + if ($fields !== []) { + $this->forge->addColumn('exam_drafts', $fields); + } } public function down() diff --git a/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php b/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php index 075e100..b9de9da 100644 --- a/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php +++ b/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php @@ -8,6 +8,10 @@ class AddFinalPdfToExamDrafts extends Migration { public function up() { + if ($this->db->fieldExists('final_pdf_file', 'exam_drafts')) { + return; + } + $this->forge->addColumn('exam_drafts', [ 'final_pdf_file' => [ 'type' => 'VARCHAR', @@ -20,7 +24,8 @@ class AddFinalPdfToExamDrafts extends Migration public function down() { - $this->forge->dropColumn('exam_drafts', 'final_pdf_file'); + if ($this->db->fieldExists('final_pdf_file', 'exam_drafts')) { + $this->forge->dropColumn('exam_drafts', 'final_pdf_file'); + } } } - diff --git a/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php b/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php index 3638d8d..67533e3 100644 --- a/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php +++ b/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php @@ -8,6 +8,10 @@ class AddIsLegacyToExamDrafts extends Migration { public function up() { + if ($this->db->fieldExists('is_legacy', 'exam_drafts')) { + return; + } + $this->forge->addColumn('exam_drafts', [ 'is_legacy' => [ 'type' => 'TINYINT', @@ -20,7 +24,8 @@ class AddIsLegacyToExamDrafts extends Migration public function down() { - $this->forge->dropColumn('exam_drafts', 'is_legacy'); + if ($this->db->fieldExists('is_legacy', 'exam_drafts')) { + $this->forge->dropColumn('exam_drafts', 'is_legacy'); + } } } - diff --git a/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php b/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php index 772f8b3..7991728 100644 --- a/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php +++ b/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php @@ -24,7 +24,7 @@ class CreateClassProgressAttachments extends Migration $this->forge->addKey('id', true); $this->forge->addKey('report_id'); - $this->forge->createTable('class_progress_attachments'); + $this->forge->createTable('class_progress_attachments', true); } public function down() diff --git a/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php b/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php index 00ab54c..fe4813e 100644 --- a/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php +++ b/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php @@ -26,7 +26,7 @@ class CreatePlacementLevels extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_student_school_year'); $this->forge->addKey('school_year'); - $this->forge->createTable('placement_levels'); + $this->forge->createTable('placement_levels', true); } public function down() diff --git a/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php b/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php index 2edb3f5..234ec2b 100644 --- a/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php +++ b/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php @@ -24,7 +24,7 @@ class CreatePlacementBatches extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['placement_test', 'school_year']); - $this->forge->createTable('placement_batches'); + $this->forge->createTable('placement_batches', true); } public function down() diff --git a/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php b/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php index 6a63351..98faa07 100644 --- a/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php +++ b/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php @@ -26,7 +26,7 @@ class CreatePlacementScores extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['batch_id', 'student_id'], 'unique_batch_student'); $this->forge->addKey('batch_id'); - $this->forge->createTable('placement_scores'); + $this->forge->createTable('placement_scores', true); } public function down() diff --git a/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php index 54e49f3..257c14d 100644 --- a/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php +++ b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php @@ -67,7 +67,7 @@ class CreateReportCardAcknowledgements extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['parent_id', 'student_id', 'school_year', 'semester'], false, true); - $this->forge->createTable('report_card_acknowledgements'); + $this->forge->createTable('report_card_acknowledgements', true); } public function down() diff --git a/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php b/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php index 40fc06e..7c00768 100644 --- a/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php +++ b/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php @@ -75,7 +75,7 @@ class CreateCertificateRecords extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey('certificate_number'); $this->forge->addKey(['school_year', 'student_id']); - $this->forge->createTable('certificate_records'); + $this->forge->createTable('certificate_records', true); } public function down() diff --git a/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php b/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php index 7017d42..a90fd45 100644 --- a/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php +++ b/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php @@ -60,7 +60,7 @@ class CreateBelowSixtyDecisions extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['student_id', 'semester', 'school_year']); $this->forge->addKey(['school_year', 'semester']); - $this->forge->createTable('below_sixty_decisions'); + $this->forge->createTable('below_sixty_decisions', true); } public function down() diff --git a/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php b/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php index e3ce06d..bfa4f3c 100644 --- a/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php +++ b/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php @@ -75,7 +75,7 @@ class CreateStudentDecisions extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['student_id', 'semester', 'school_year']); $this->forge->addKey(['school_year', 'semester']); - $this->forge->createTable('student_decisions'); + $this->forge->createTable('student_decisions', true); } public function down() diff --git a/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php b/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php index 51f6c7a..17df115 100644 --- a/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php +++ b/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php @@ -40,8 +40,10 @@ class AddVerificationTokenToCertificateRecords extends Migration ->update(['verification_token' => $this->generateToken()]); } - $this->forge->addUniqueKey('verification_token', self::INDEX_NAME); - $this->forge->processIndexes('certificate_records'); + if (! $this->indexExists('certificate_records', self::INDEX_NAME)) { + $this->forge->addUniqueKey('verification_token', self::INDEX_NAME); + $this->forge->processIndexes('certificate_records'); + } } public function down() @@ -70,4 +72,15 @@ class AddVerificationTokenToCertificateRecords extends Migration return $token; } + + private function indexExists(string $table, string $indexName): bool + { + foreach ($this->db->getIndexData($table) as $index) { + if (($index->name ?? '') === $indexName) { + return true; + } + } + + return false; + } } diff --git a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php index 594c33a..142b080 100644 --- a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php +++ b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php @@ -149,11 +149,11 @@ class FinancialSystemLedgerCleanup extends Migration $defaults = [ 'tuition_calculator_version' => 'old', - 'youth_fee' => '200.00', - 'new_tuition_full_amount' => '370.00', - 'new_tuition_youth_amount' => '200.00', - 'new_tuition_second_student_discount' => '50.00', - 'new_tuition_third_student_discount' => '50.00', + 'youth_fee' => '380.00', + 'new_tuition_full_amount' => '380.00', + 'new_tuition_youth_amount' => '380.00', + 'new_tuition_second_student_discount' => '100.00', + 'new_tuition_third_student_discount' => '100.00', 'new_tuition_fourth_plus_discount' => '100.00', ]; diff --git a/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php b/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php index 8ee519b..de162be 100644 --- a/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php +++ b/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php @@ -104,7 +104,7 @@ class CreateSchoolYears extends Migration $this->forge->addKey('id', true); $this->forge->addKey('name', false, true); $this->forge->addKey('status'); - $this->forge->createTable('school_years'); + $this->forge->createTable('school_years', true); } else { $this->ensureSchoolYearColumns(); } @@ -193,7 +193,7 @@ class CreateSchoolYears extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['source_school_year_id', 'status']); - $this->forge->createTable('school_year_closing_batches'); + $this->forge->createTable('school_year_closing_batches', true); } if (! $this->db->tableExists('school_year_closing_items')) { @@ -214,7 +214,7 @@ class CreateSchoolYears extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['closing_batch_id', 'family_id'], false, true); - $this->forge->createTable('school_year_closing_items'); + $this->forge->createTable('school_year_closing_items', true); } if (! $this->db->tableExists('school_year_transition_logs')) { @@ -230,7 +230,7 @@ class CreateSchoolYears extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['school_year_id', 'created_at']); - $this->forge->createTable('school_year_transition_logs'); + $this->forge->createTable('school_year_transition_logs', true); } } diff --git a/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php b/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php index 4fee4dc..7899f28 100644 --- a/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php +++ b/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php @@ -33,7 +33,7 @@ class CreateStudentSectionDistributionDrafts extends Migration $this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year'); $this->forge->addKey(['class_id', 'school_year', 'status'], false, false, 'distribution_draft_class_year_status'); $this->forge->addKey(['class_section_id', 'school_year'], false, false, 'distribution_draft_section_year'); - $this->forge->createTable('student_section_distribution_drafts'); + $this->forge->createTable('student_section_distribution_drafts', true); } public function down() diff --git a/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php b/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php index 50fcbcc..6ac3f53 100644 --- a/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php +++ b/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php @@ -63,7 +63,7 @@ class CreateParentPolicyAcceptances extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['parent_id', 'school_year'], 'uq_parent_policy_year'); $this->forge->addKey('school_year'); - $this->forge->createTable('parent_policy_acceptances'); + $this->forge->createTable('parent_policy_acceptances', true); $this->backfillExistingAcceptances(); } diff --git a/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php b/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php index fe54066..ba201a7 100644 --- a/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php +++ b/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php @@ -39,7 +39,7 @@ class CreateEnrollmentPhaseTwoTables extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['school_year', 'grade_class_id']); - $this->forge->createTable('enrollment_age_rules'); + $this->forge->createTable('enrollment_age_rules', true); } if (! $this->db->tableExists('enrollment_flags')) { @@ -59,7 +59,7 @@ class CreateEnrollmentPhaseTwoTables extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['student_id', 'school_year', 'flag_type']); - $this->forge->createTable('enrollment_flags'); + $this->forge->createTable('enrollment_flags', true); } if (! $this->db->tableExists('enrollment_transition_audits')) { @@ -77,7 +77,7 @@ class CreateEnrollmentPhaseTwoTables extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['student_id', 'school_year', 'created_at']); - $this->forge->createTable('enrollment_transition_audits'); + $this->forge->createTable('enrollment_transition_audits', true); } } diff --git a/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php b/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php index 1a40eb8..620045f 100644 --- a/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php +++ b/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php @@ -43,7 +43,7 @@ class CreateEnrollmentEmailRecords extends Migration ]); $this->forge->addKey('id', true); $this->forge->addKey(['school_year', 'parent_user_id']); - $this->forge->createTable('enrollment_email_records'); + $this->forge->createTable('enrollment_email_records', true); } public function down() diff --git a/app/Database/Migrations/2026-08-08-000100_CreateEnrollmentExceptions.php b/app/Database/Migrations/2026-08-08-000100_CreateEnrollmentExceptions.php new file mode 100644 index 0000000..002e7e0 --- /dev/null +++ b/app/Database/Migrations/2026-08-08-000100_CreateEnrollmentExceptions.php @@ -0,0 +1,48 @@ +db->tableExists('enrollment_exceptions')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'source_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'active'], + 'reason_code' => ['type' => 'VARCHAR', 'constraint' => 80], + 'reason_note' => ['type' => 'TEXT'], + 'bypassed_rule_codes_json' => ['type' => 'TEXT', 'null' => true], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'approved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'starts_at' => ['type' => 'DATETIME', 'null' => true], + 'expires_at' => ['type' => 'DATETIME', 'null' => true], + 'used_at' => ['type' => 'DATETIME', 'null' => true], + 'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'revoked_at' => ['type' => 'DATETIME', 'null' => true], + 'revoked_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'revocation_reason' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['parent_id', 'student_id', 'school_year', 'status'], false, false, 'idx_enrollment_exceptions_scope_status'); + $this->forge->addKey(['student_id', 'school_year'], false, false, 'idx_enrollment_exceptions_student_year'); + $this->forge->addKey(['status', 'expires_at'], false, false, 'idx_enrollment_exceptions_status_expiry'); + $this->forge->createTable('enrollment_exceptions', true); + } + + public function down() + { + $this->forge->dropTable('enrollment_exceptions', true); + } +} diff --git a/app/Database/Migrations/2026-08-08-000200_GrantEnrollmentExceptionPermission.php b/app/Database/Migrations/2026-08-08-000200_GrantEnrollmentExceptionPermission.php new file mode 100644 index 0000000..c041f2d --- /dev/null +++ b/app/Database/Migrations/2026-08-08-000200_GrantEnrollmentExceptionPermission.php @@ -0,0 +1,64 @@ +db->tableExists('permissions')) { + return; + } + + $permission = $this->db->table('permissions') + ->where('name', $this->permissionName) + ->limit(1) + ->get() + ->getRowArray(); + + if ($permission === null) { + $insert = [ + 'name' => $this->permissionName, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($this->db->fieldExists('description', 'permissions')) { + $insert['description'] = 'Manage scoped enrollment eligibility exceptions.'; + } + $this->db->table('permissions')->insert($insert); + $permissionId = (int) $this->db->insertID(); + } else { + $permissionId = (int) $permission['id']; + } + + // The plan requires a narrow permission but not an automatic broad role grant. + // Assign enrollment.exception.manage through the existing role-permission UI. + } + + public function down() + { + if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions')) { + return; + } + + $permission = $this->db->table('permissions') + ->where('name', $this->permissionName) + ->limit(1) + ->get() + ->getRowArray(); + if ($permission === null) { + return; + } + + $this->db->table('role_permissions') + ->where('permission_id', (int) $permission['id']) + ->delete(); + $this->db->table('permissions') + ->where('id', (int) $permission['id']) + ->delete(); + } +} diff --git a/app/Database/Migrations/2026-08-14-000100_UpdateTuitionFeeDefaults.php b/app/Database/Migrations/2026-08-14-000100_UpdateTuitionFeeDefaults.php new file mode 100644 index 0000000..9864eb5 --- /dev/null +++ b/app/Database/Migrations/2026-08-14-000100_UpdateTuitionFeeDefaults.php @@ -0,0 +1,107 @@ +db->tableExists('configuration')) { + return; + } + + $this->forceConfig('first_student_fee', '380.00'); + $this->forceConfig('second_student_fee', '280.00'); + $this->forceConfig('new_tuition_full_amount', '380.00'); + $this->forceConfig('new_tuition_second_student_discount', '100.00'); + $this->aliasAndForceConfig('Youth_fee', 'youth_fee', '380.00'); + } + + public function down() + { + if (!$this->db->tableExists('configuration')) { + return; + } + + $this->rollbackConfig('first_student_fee', '370.00'); + $this->rollbackConfig('second_student_fee', '200.00'); + $this->rollbackConfig('youth_fee', '200.00'); + $this->rollbackConfig('new_tuition_full_amount', '370.00'); + $this->rollbackConfig('new_tuition_second_student_discount', '50.00'); + } + + protected function aliasAndForceConfig(string $legacyKey, string $canonicalKey, string $value): void + { + $legacy = $this->db->table('configuration') + ->select('id, config_value') + ->where('config_key', $legacyKey) + ->orderBy('id', 'ASC') + ->get() + ->getRowArray(); + + if ($legacy && strcasecmp($legacyKey, $canonicalKey) !== 0) { + $this->db->table('configuration') + ->where('id', (int) $legacy['id']) + ->update(['config_key' => $canonicalKey, 'config_value' => $value]); + } + + $this->forceConfig($canonicalKey, $value); + } + + protected function forceConfig(string $key, string $value): void + { + $row = $this->db->table('configuration') + ->select('id') + ->where('config_key', $key) + ->orderBy('id', 'ASC') + ->get() + ->getRowArray(); + + if (!$row) { + $this->db->table('configuration')->insert([ + 'config_key' => $key, + 'config_value' => $value, + ]); + return; + } + + $this->db->table('configuration') + ->where('config_key', $key) + ->update(['config_value' => $value]); + } + + protected function upsertConfig(string $key, string $value, array $legacyValues): void + { + $row = $this->db->table('configuration') + ->select('id, config_value') + ->where('config_key', $key) + ->orderBy('id', 'ASC') + ->get() + ->getRowArray(); + + if (!$row) { + $this->db->table('configuration')->insert([ + 'config_key' => $key, + 'config_value' => $value, + ]); + return; + } + + $current = trim((string) ($row['config_value'] ?? '')); + if (in_array($current, $legacyValues, true)) { + $this->db->table('configuration') + ->where('config_key', $key) + ->update(['config_value' => $value]); + } + } + + protected function rollbackConfig(string $key, string $value): void + { + $this->db->table('configuration') + ->where('config_key', $key) + ->where('config_value', '380.00') + ->update(['config_value' => $value]); + } +} diff --git a/app/Database/Migrations/2026-08-14-000200_EnrollmentCarryOverAndExceptionUniqueness.php b/app/Database/Migrations/2026-08-14-000200_EnrollmentCarryOverAndExceptionUniqueness.php new file mode 100644 index 0000000..6ac15b2 --- /dev/null +++ b/app/Database/Migrations/2026-08-14-000200_EnrollmentCarryOverAndExceptionUniqueness.php @@ -0,0 +1,50 @@ +db->tableExists('school_years') && $this->db->fieldExists('carry_over_balance_behavior', 'school_years')) { + $this->db->table('school_years') + ->groupStart() + ->where('carry_over_balance_behavior', 'information_only') + ->orWhere('carry_over_balance_behavior', null) + ->orWhere('carry_over_balance_behavior', '') + ->groupEnd() + ->update(['carry_over_balance_behavior' => 'submission_blocked_until_payment']); + } + + if (! $this->db->tableExists('enrollment_exceptions')) { + return; + } + + $indexes = $this->db->query('SHOW INDEX FROM enrollment_exceptions')->getResultArray(); + $names = array_map(static fn (array $row): string => (string) ($row['Key_name'] ?? ''), $indexes); + if (in_array('uniq_enrollment_exceptions_scope_status', $names, true)) { + return; + } + + try { + $this->db->query( + 'ALTER TABLE enrollment_exceptions ADD UNIQUE INDEX uniq_enrollment_exceptions_scope_status (parent_id, student_id, school_year, status)' + ); + } catch (\Throwable $e) { + log_message('error', 'Unable to add unique enrollment exception index: ' . $e->getMessage()); + } + } + + public function down() + { + if ($this->db->tableExists('enrollment_exceptions')) { + try { + $this->db->query('ALTER TABLE enrollment_exceptions DROP INDEX uniq_enrollment_exceptions_scope_status'); + } catch (\Throwable $e) { + // Index may not exist. + } + } + } +} diff --git a/app/Database/Migrations/2026-08-14-000300_CreateFinancialAidRequests.php b/app/Database/Migrations/2026-08-14-000300_CreateFinancialAidRequests.php new file mode 100644 index 0000000..6282308 --- /dev/null +++ b/app/Database/Migrations/2026-08-14-000300_CreateFinancialAidRequests.php @@ -0,0 +1,44 @@ +db->tableExists('financial_aid_requests')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'student_ids_json' => ['type' => 'TEXT', 'null' => true], + 'household_size' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'need_statement' => ['type' => 'TEXT'], + 'requested_amount' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'submitted'], + 'admin_amount' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true], + 'admin_note' => ['type' => 'TEXT', 'null' => true], + 'reviewed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'reviewed_at' => ['type' => 'DATETIME', 'null' => true], + 'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'discount_usage_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'voucher_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['parent_id', 'school_year', 'status'], false, false, 'idx_financial_aid_parent_year_status'); + $this->forge->addKey(['school_year', 'status'], false, false, 'idx_financial_aid_year_status'); + $this->forge->createTable('financial_aid_requests', true); + } + + public function down() + { + $this->forge->dropTable('financial_aid_requests', true); + } +} diff --git a/app/Database/Migrations/2026-08-14-000400_AddFinancialAidNavItem.php b/app/Database/Migrations/2026-08-14-000400_AddFinancialAidNavItem.php new file mode 100644 index 0000000..7535263 --- /dev/null +++ b/app/Database/Migrations/2026-08-14-000400_AddFinancialAidNavItem.php @@ -0,0 +1,108 @@ +db->tableExists('nav_items')) { + return; + } + + $parentColumn = $this->parentColumn(); + $existingQuery = $this->db->table('nav_items') + ->where('url', $this->url) + ->get(); + + $existing = $existingQuery !== false ? $existingQuery->getRowArray() : null; + if ($existing !== null) { + return; + } + + $parentBuilder = $this->db->table('nav_items') + ->where('label', 'Financial'); + + if ($parentColumn !== null) { + $parentBuilder->where($parentColumn, null); + } + + $parentQuery = $parentBuilder->get(); + $parent = $parentQuery !== false ? $parentQuery->getRowArray() : null; + + $insert = [ + 'label' => 'Financial Aid', + 'url' => $this->url, + 'sort_order' => 9, + 'is_enabled' => 1, + 'created_at' => date('Y-m-d H:i:s'), + ]; + + if ($parentColumn !== null) { + $insert[$parentColumn] = $parent['id'] ?? null; + } + + $this->db->table('nav_items')->insert($insert); + + $navItemId = (int) $this->db->insertID(); + if ( + $navItemId <= 0 + || ! $this->db->tableExists('role_nav_items') + || ! $this->db->fieldExists('role', 'role_nav_items') + || ! $this->db->fieldExists('nav_item_id', 'role_nav_items') + ) { + return; + } + + foreach (['administrator', 'principal', 'vice_principal'] as $role) { + $this->db->table('role_nav_items')->insert([ + 'role' => $role, + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + public function down(): void + { + if (! $this->db->tableExists('nav_items')) { + return; + } + + $query = $this->db->table('nav_items') + ->where('url', $this->url) + ->get(); + + $row = $query !== false ? $query->getRowArray() : null; + if ($row === null) { + return; + } + + if ($this->db->tableExists('role_nav_items')) { + $this->db->table('role_nav_items') + ->where('nav_item_id', (int) $row['id']) + ->delete(); + } + + $this->db->table('nav_items') + ->where('id', (int) $row['id']) + ->delete(); + } + + private function parentColumn(): ?string + { + if ($this->db->fieldExists('parent_id', 'nav_items')) { + return 'parent_id'; + } + + if ($this->db->fieldExists('menu_parent_id', 'nav_items')) { + return 'menu_parent_id'; + } + + return null; + } +} diff --git a/app/Database/Migrations/2026-08-14-000500_AddEnrollmentExceptionFamilyStudentIds.php b/app/Database/Migrations/2026-08-14-000500_AddEnrollmentExceptionFamilyStudentIds.php new file mode 100644 index 0000000..c7bc4d5 --- /dev/null +++ b/app/Database/Migrations/2026-08-14-000500_AddEnrollmentExceptionFamilyStudentIds.php @@ -0,0 +1,38 @@ +db->tableExists('enrollment_exceptions')) { + return; + } + + if ($this->db->fieldExists('family_student_ids_json', 'enrollment_exceptions')) { + return; + } + + $this->forge->addColumn('enrollment_exceptions', [ + 'family_student_ids_json' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'bypassed_rule_codes_json', + ], + ]); + } + + public function down(): void + { + if (! $this->db->tableExists('enrollment_exceptions')) { + return; + } + + if ($this->db->fieldExists('family_student_ids_json', 'enrollment_exceptions')) { + $this->forge->dropColumn('enrollment_exceptions', 'family_student_ids_json'); + } + } +} diff --git a/app/Database/Migrations/2026-08-14-000600_GrantEnrollmentExceptionPermissionToAdminRoles.php b/app/Database/Migrations/2026-08-14-000600_GrantEnrollmentExceptionPermissionToAdminRoles.php new file mode 100644 index 0000000..934cc48 --- /dev/null +++ b/app/Database/Migrations/2026-08-14-000600_GrantEnrollmentExceptionPermissionToAdminRoles.php @@ -0,0 +1,140 @@ + + */ + private array $roleNames = [ + 'administrator', + 'admin', + 'principal', + 'vice principal', + 'vice_principal', + ]; + + public function up(): void + { + if ( + ! $this->db->tableExists('roles') + || ! $this->db->tableExists('permissions') + || ! $this->db->tableExists('role_permissions') + ) { + return; + } + + $now = date('Y-m-d H:i:s'); + $permission = $this->db->table('permissions') + ->select('id') + ->where('name', $this->permissionName) + ->get() + ->getRowArray(); + + if ($permission === null) { + $insert = [ + 'name' => $this->permissionName, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if ($this->db->fieldExists('description', 'permissions')) { + $insert['description'] = 'Manage scoped enrollment eligibility exceptions.'; + } + + $this->db->table('permissions')->insert($insert); + $permissionId = (int) $this->db->insertID(); + } else { + $permissionId = (int) $permission['id']; + } + + if ($permissionId <= 0) { + return; + } + + $roles = $this->db->table('roles') + ->select('id') + ->whereIn('name', $this->roleNames) + ->get() + ->getResultArray(); + + foreach ($roles as $role) { + $roleId = (int) ($role['id'] ?? 0); + if ($roleId <= 0) { + continue; + } + + $existing = $this->db->table('role_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permissionId) + ->get() + ->getRowArray(); + + $grant = [ + 'can_create' => 1, + 'can_read' => 1, + 'can_update' => 1, + 'can_delete' => 1, + 'updated_at' => $now, + ]; + + if ($this->db->fieldExists('can_manage', 'role_permissions')) { + $grant['can_manage'] = 1; + } + + if ($existing === null) { + $grant['role_id'] = $roleId; + $grant['permission_id'] = $permissionId; + $grant['created_at'] = $now; + + $this->db->table('role_permissions')->insert($grant); + continue; + } + + $this->db->table('role_permissions') + ->where('id', (int) $existing['id']) + ->update($grant); + } + } + + public function down(): void + { + if ( + ! $this->db->tableExists('roles') + || ! $this->db->tableExists('permissions') + || ! $this->db->tableExists('role_permissions') + ) { + return; + } + + $permission = $this->db->table('permissions') + ->select('id') + ->where('name', $this->permissionName) + ->get() + ->getRowArray(); + + if ($permission === null) { + return; + } + + $roles = $this->db->table('roles') + ->select('id') + ->whereIn('name', $this->roleNames) + ->get() + ->getResultArray(); + $roleIds = array_values(array_filter(array_map(static fn (array $role): int => (int) ($role['id'] ?? 0), $roles))); + + if ($roleIds === []) { + return; + } + + $this->db->table('role_permissions') + ->where('permission_id', (int) $permission['id']) + ->whereIn('role_id', $roleIds) + ->delete(); + } +} diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php index d9fd30b..6edd437 100644 --- a/app/Database/Seeds/NavSeeder.php +++ b/app/Database/Seeds/NavSeeder.php @@ -93,6 +93,7 @@ class NavSeeder extends Seeder ['parent'=>'Financial','label'=>'Manual Payment','url'=>'payment/manual_pay','sort_order'=>6], ['parent'=>'Financial','label'=>'Refund Management','url'=>'refunds/list','sort_order'=>7], ['parent'=>'Financial','label'=>'Reimbursement Management','url'=>'reimbursements/index','sort_order'=>8], + ['parent'=>'Financial','label'=>'Financial Aid','url'=>'administrator/financial-aid','sort_order'=>9], // Printables ['parent'=>'Printables','label'=>'Badges','url'=>'printables_reports/badge_form','sort_order'=>1], diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index 6024598..7370afa 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -646,12 +646,9 @@ class InvoiceLedgerService 'grade_fee' => $this->configurationModel->getConfig('grade_fee'), 'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'), 'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'), - 'youth_fee' => $this->configurationModel->getConfig('youth_fee'), + 'youth_fee' => $this->configurationModel->getConfig('first_student_fee'), 'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'), - 'new_tuition_youth_amount' => $this->configurationModel->getConfig('new_tuition_youth_amount'), 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'), - 'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'), - 'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'), ]; } diff --git a/app/Libraries/Tuition/GradeLevelParser.php b/app/Libraries/Tuition/GradeLevelParser.php index 086006f..e24a9af 100644 --- a/app/Libraries/Tuition/GradeLevelParser.php +++ b/app/Libraries/Tuition/GradeLevelParser.php @@ -37,4 +37,17 @@ final class GradeLevelParser return 999; } + + public static function isYouth($grade): bool + { + if (! is_string($grade) && ! is_numeric($grade)) { + return false; + } + + $value = strtoupper(trim((string) $grade)); + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + $value = str_replace(['.', '_', '-'], ['', '', ' '], $value); + + return (bool) preg_match('/^Y(?:OUTH)?\s*\d*$/', $value); + } } diff --git a/app/Libraries/Tuition/NewTuitionCalculatorService.php b/app/Libraries/Tuition/NewTuitionCalculatorService.php index defb35d..1b2c75c 100644 --- a/app/Libraries/Tuition/NewTuitionCalculatorService.php +++ b/app/Libraries/Tuition/NewTuitionCalculatorService.php @@ -9,11 +9,8 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface public function calculateFamilyTuition(array $students, array $config): array { $gradeFee = (int) ($config['grade_fee'] ?? 9); - $fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? 370); - $youthAmountCents = $this->toCents($config['new_tuition_youth_amount'] ?? $config['youth_fee'] ?? 200); - $secondDiscountCents = $this->toCents($config['new_tuition_second_student_discount'] ?? 50); - $thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50); - $fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100); + $fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? $config['first_student_fee'] ?? 380); + $additionalDiscountCents = $this->additionalDiscountCents($config, $fullAmountCents); usort($students, function (array $left, array $right) use ($gradeFee): int { $leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee); @@ -23,45 +20,20 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface }); $details = []; - $regularPosition = 0; + $familyPosition = 0; foreach (array_values($students) as $student) { - $level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee); - - if ($level > $gradeFee) { - $position = null; - $discountCents = 0; - $rule = 'new_youth_unit_price'; - $baseAmountCents = $youthAmountCents; - } else { - $regularPosition++; - $position = $regularPosition; - - if ($position === 1) { - $discountCents = 0; - $rule = 'new_first_student_full_amount'; - } elseif ($position === 2) { - $discountCents = $secondDiscountCents; - $rule = 'new_second_student_discount'; - } elseif ($position === 3) { - $discountCents = $thirdDiscountCents; - $rule = 'new_third_student_discount'; - } else { - $discountCents = $fourthPlusDiscountCents; - $rule = 'new_fourth_plus_student_discount'; - } - - $baseAmountCents = $fullAmountCents; - } - - $amountCents = max(0, $baseAmountCents - $discountCents); + $familyPosition++; + $discountCents = $familyPosition === 1 ? 0 : $additionalDiscountCents; + $rule = $familyPosition === 1 ? 'new_first_student_full_amount' : 'new_additional_student_discount'; + $amountCents = max(0, $fullAmountCents - $discountCents); $details[] = [ 'student_id' => (int) ($student['student_id'] ?? 0), 'student_name' => (string) ($student['student_name'] ?? ''), 'grade_level' => $student['grade_level'] ?? null, - 'family_position' => $position, - 'full_amount' => $this->fromCents($baseAmountCents), + 'family_position' => $familyPosition, + 'full_amount' => $this->fromCents($fullAmountCents), 'discount' => $this->fromCents($discountCents), 'rule' => $rule, 'amount' => $this->fromCents($amountCents), @@ -77,6 +49,19 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface ]; } + private function additionalDiscountCents(array $config, int $fullAmountCents): int + { + if (array_key_exists('new_tuition_second_student_discount', $config) && $config['new_tuition_second_student_discount'] !== null && $config['new_tuition_second_student_discount'] !== '') { + return $this->toCents($config['new_tuition_second_student_discount']); + } + + if (array_key_exists('second_student_fee', $config) && $config['second_student_fee'] !== null && $config['second_student_fee'] !== '') { + return max(0, $fullAmountCents - $this->toCents($config['second_student_fee'])); + } + + return $this->toCents(100); + } + private function toCents($amount): int { return (int) round(((float) $amount) * 100); diff --git a/app/Libraries/Tuition/OldTuitionCalculatorService.php b/app/Libraries/Tuition/OldTuitionCalculatorService.php index 7049d84..6d3af20 100644 --- a/app/Libraries/Tuition/OldTuitionCalculatorService.php +++ b/app/Libraries/Tuition/OldTuitionCalculatorService.php @@ -9,9 +9,10 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface public function calculateFamilyTuition(array $students, array $config): array { $gradeFee = (int) ($config['grade_fee'] ?? 9); - $firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370); - $secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200); - $youthFee = $this->toCents($config['youth_fee'] ?? 200); + $firstStudentFee = $this->toCents($config['first_student_fee'] ?? $config['new_tuition_full_amount'] ?? 380); + $secondStudentFee = isset($config['second_student_fee']) && $config['second_student_fee'] !== '' && $config['second_student_fee'] !== null + ? $this->toCents($config['second_student_fee']) + : max(0, $firstStudentFee - 10000); usort($students, function (array $left, array $right) use ($gradeFee): int { $leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee); @@ -20,20 +21,13 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface return [$leftLevel, (int) ($left['student_id'] ?? 0)] <=> [$rightLevel, (int) ($right['student_id'] ?? 0)]; }); - $regularCount = 0; + $familyPosition = 0; $details = []; foreach ($students as $student) { - $level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee); - - if ($level > $gradeFee) { - $amountCents = $youthFee; - $rule = 'old_youth_fee'; - } else { - $regularCount++; - $amountCents = $regularCount === 1 ? $firstStudentFee : $secondStudentFee; - $rule = $regularCount === 1 ? 'old_first_student_fee' : 'old_second_student_fee'; - } + $familyPosition++; + $amountCents = $familyPosition === 1 ? $firstStudentFee : $secondStudentFee; + $rule = $familyPosition === 1 ? 'old_first_student_fee' : 'old_additional_student_fee'; $details[] = [ 'student_id' => (int) ($student['student_id'] ?? 0), diff --git a/app/Libraries/Tuition/TuitionForecastService.php b/app/Libraries/Tuition/TuitionForecastService.php index f9ce8d4..83c63fa 100644 --- a/app/Libraries/Tuition/TuitionForecastService.php +++ b/app/Libraries/Tuition/TuitionForecastService.php @@ -43,7 +43,6 @@ class TuitionForecastService $mode = $this->normalizeMode($mode); $options = $this->normalizeOptions($options); $this->unitPriceOverride = $options['unit_price']; - $this->youthUnitPriceOverride = $options['youth_unit_price']; $tuitionConfig = $this->getTuitionConfig(); $familyRows = []; $summary = [ @@ -119,7 +118,7 @@ class TuitionForecastService $summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition']; $summary['projected_income'] = $mode === 'old' ? $summary['old_projected_income'] : $summary['new_projected_income']; $summary['unit_price'] = number_format((float) ($tuitionConfig['new_tuition_full_amount'] ?? 0), 2, '.', ''); - $summary['youth_unit_price'] = number_format((float) ($tuitionConfig['new_tuition_youth_amount'] ?? 0), 2, '.', ''); + $summary['youth_unit_price'] = $summary['unit_price']; return [ 'school_year' => $schoolYear, @@ -545,32 +544,18 @@ class TuitionForecastService 'grade_fee' => $this->configurationModel->getConfig('grade_fee'), 'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'), 'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'), - 'youth_fee' => $this->configurationModel->getConfig('youth_fee') ?? '200.00', + 'youth_fee' => $this->configurationModel->getConfig('first_student_fee') ?? '380.00', 'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(), - 'new_tuition_youth_amount' => $this->normalizedYouthUnitPriceOverride(), - 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'), - 'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'), - 'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'), + 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount') ?? '100.00', ]; } protected ?string $unitPriceOverride = null; - protected ?string $youthUnitPriceOverride = null; protected function normalizedUnitPriceOverride(): string { return $this->unitPriceOverride - ?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00'); - } - - protected function normalizedYouthUnitPriceOverride(): string - { - return $this->youthUnitPriceOverride - ?? (string) ( - $this->configurationModel->getConfig('new_tuition_youth_amount') - ?? $this->configurationModel->getConfig('youth_fee') - ?? '200.00' - ); + ?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '380.00'); } protected function normalizeMoney($value): ?string diff --git a/app/Models/EnrollmentExceptionModel.php b/app/Models/EnrollmentExceptionModel.php new file mode 100644 index 0000000..1c719ad --- /dev/null +++ b/app/Models/EnrollmentExceptionModel.php @@ -0,0 +1,50 @@ + 'required|integer', + 'student_id' => 'required|integer', + 'school_year' => 'required|string|max_length[20]', + 'source_school_year' => 'permit_empty|string|max_length[20]', + 'status' => 'required|in_list[active,used,revoked,expired]', + 'reason_code' => 'required|string|max_length[80]', + 'reason_note' => 'required|string', + 'created_by' => 'permit_empty|integer', + 'approved_by' => 'permit_empty|integer', + 'enrollment_id' => 'permit_empty|integer', + 'revoked_by' => 'permit_empty|integer', + ]; +} diff --git a/app/Models/FinancialAidRequestModel.php b/app/Models/FinancialAidRequestModel.php new file mode 100644 index 0000000..ec5ea86 --- /dev/null +++ b/app/Models/FinancialAidRequestModel.php @@ -0,0 +1,41 @@ +where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->whereIn('status', ['submitted', 'under_review']) + ->orderBy('id', 'DESC') + ->first(); + } +} diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php index 05d615b..62491a5 100644 --- a/app/Services/EnrollmentTransitionService.php +++ b/app/Services/EnrollmentTransitionService.php @@ -11,10 +11,114 @@ use RuntimeException; final class EnrollmentTransitionService { + private const DECISION_ELIGIBLE = 'ELIGIBLE'; + private const DECISION_ELIGIBLE_WITH_WARNING = 'ELIGIBLE_WITH_WARNING'; + private const DECISION_INELIGIBLE = 'INELIGIBLE'; + private const DECISION_REVIEW_REQUIRED = 'REVIEW_REQUIRED'; + private const DECISION_EXCEPTION_ELIGIBLE = 'EXCEPTION_ELIGIBLE'; + private const DECISION_ALREADY_ENROLLED = 'ALREADY_ENROLLED'; + + private const NON_OVERRIDABLE_RULE_CODES = [ + 'STUDENT_NOT_LINKED', + 'SOURCE_YEAR_NOT_FOUND', + 'TARGET_YEAR_NOT_FOUND', + 'ALREADY_ENROLLED', + ]; + public function __construct(private readonly BaseConnection $db) { } + public function evaluateForParent( + int $parentId, + int $studentId, + string $sourceSchoolYear, + string $targetSchoolYear, + string $actorRole = 'parent', + ?DateTimeInterface $now = null + ): array { + $evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole, $now); + $this->ensureDecisionFields($evaluation); + + $student = $this->student($studentId); + if ($parentId <= 0 || $student === null || (int) ($student['parent_id'] ?? 0) !== $parentId) { + $this->addBlocker($evaluation, 'STUDENT_NOT_LINKED', 'This student is not linked to the signed-in parent account.'); + } + + if ($this->schoolYearByName($targetSchoolYear) === null) { + $this->addBlocker($evaluation, 'TARGET_YEAR_NOT_FOUND', 'Target school year configuration was not found.'); + } + + if ($sourceSchoolYear === '' || $this->sourceAssignment($studentId, $sourceSchoolYear) === null) { + $this->addBlocker($evaluation, 'SOURCE_YEAR_NOT_FOUND', 'Student does not belong to the closing school year.'); + } + + $existing = $this->latestEnrollment($studentId, $targetSchoolYear); + if ($this->activeEnrollmentBlocksDuplicate($existing)) { + $this->addBlocker($evaluation, 'ALREADY_ENROLLED', 'Student already has an active enrollment for the selected school year.'); + } elseif ($this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($existing)) { + $code = strtolower((string) ($existing['admission_status'] ?? '')) === 'denied' + || strtolower((string) ($existing['enrollment_status'] ?? '')) === 'denied' + ? 'DENIED' + : 'WITHDRAWN'; + $this->addBlocker($evaluation, $code, 'Student has a target-year enrollment status that requires administration review.'); + } + + $this->applyHouseholdLastNameRule($evaluation, $parentId); + $this->applyFinancialRule($evaluation, $parentId, $sourceSchoolYear, $targetSchoolYear); + $this->applyCarriedForwardLastNameException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear); + $this->deriveAcademicRuleCodes($evaluation); + $this->applyScopedException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear); + $this->finalizeParentDecision($evaluation); + $this->appendFlagsFromRuleCodes($evaluation); + + return $evaluation; + } + + public function markExceptionUsed(int $exceptionId, int $enrollmentId): void + { + if ($exceptionId <= 0 || $enrollmentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { + return; + } + + $this->db->table('enrollment_exceptions') + ->where('id', $exceptionId) + ->where('status', 'active') + ->update([ + 'status' => 'used', + 'used_at' => date('Y-m-d H:i:s'), + 'enrollment_id' => $enrollmentId, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + public function auditEnrollmentDecision( + int $studentId, + string $targetSchoolYear, + ?string $sourceSchoolYear, + string $action, + ?int $performedBy, + ?array $original, + array $new, + string $reason + ): void { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return; + } + + $this->db->table('enrollment_transition_audits')->insert([ + 'student_id' => $studentId, + 'school_year' => $targetSchoolYear, + 'source_school_year' => $sourceSchoolYear, + 'action' => $action, + 'performed_by' => $performedBy, + 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, + 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), + 'reason' => $reason, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + public function evaluate( int $studentId, string $sourceSchoolYear, @@ -274,7 +378,9 @@ final class EnrollmentTransitionService if ($opensAt !== null && $now < $opensAt) { $result['registration_window_status'] = 'not_open'; - $result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.'; + if ($actorRole !== 'admin') { + $result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.'; + } return; } @@ -576,37 +682,483 @@ final class EnrollmentTransitionService } } - private function writeFlags(array $evaluation, ?int $performedBy): void + public function syncDashboardBlockageFlags(string $targetSchoolYear, ?int $performedBy = null): int { if (! $this->db->tableExists('enrollment_flags')) { - return; + return 0; } - foreach ($evaluation['flags'] as $flag) { - $existing = $this->db->table('enrollment_flags') - ->where('student_id', (int) $evaluation['student_id']) - ->where('school_year', (string) $evaluation['target_school_year']) - ->where('flag_type', (string) $flag['flag_type']) - ->where('status', 'open') - ->limit(1) - ->get() - ->getRowArray(); + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return 0; + } - if ($existing !== null) { + $students = $this->sourceYearStudents($sourceSchoolYear); + if ($students === []) { + return 0; + } + + $decisions = $this->latestDecisionsByStudent($sourceSchoolYear); + $enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear); + $bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear); + $written = 0; + + foreach ($students as $student) { + $studentId = (int) ($student['student_id'] ?? 0); + if ($studentId <= 0) { continue; } - $this->db->table('enrollment_flags')->insert([ - 'flag_type' => $flag['flag_type'], - 'student_id' => (int) $evaluation['student_id'], - 'school_year' => (string) $evaluation['target_school_year'], - 'source_school_year' => (string) $evaluation['source_school_year'], - 'status' => 'open', - 'priority' => $flag['priority'] ?? 'normal', - 'assigned_to' => $performedBy, - 'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES), - 'created_at' => date('Y-m-d H:i:s'), - ]); + $decisionRow = $decisions[$studentId] ?? null; + $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); + $alreadyEnrolled = isset($enrolledIds[$studentId]); + $flags = []; + + if ($decisionRow === null || $decision === null) { + if (! $alreadyEnrolled) { + $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', [ + 'reason' => 'Missing or unrecognized final deliberation decision.', + ]); + } + } elseif ($decision === DeliberationDecision::EXPELLED && ! $alreadyEnrolled) { + $flags[] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'); + } elseif ($decision === DeliberationDecision::WITHDRAWN && ! $alreadyEnrolled) { + $flags[] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal'); + } elseif ($decision === DeliberationDecision::DEFERRED_DECISION && ! $alreadyEnrolled) { + $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high'); + } elseif ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $flags[] = $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [ + 'current_class_section_name' => $student['class_section_name'] ?? ($decisionRow['class_section_name'] ?? null), + ]); + } + + $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, + ]); + } + + foreach ($flags as $flag) { + if ($this->flagCoveredByException((string) ($flag['flag_type'] ?? ''), $bypassCodesByStudent[$studentId] ?? [])) { + continue; + } + if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $flag, $performedBy)) { + $written++; + } + } + } + + $written += $this->syncSiblingLastNameFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy); + $written += $this->syncFinancialFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy); + + return $written; + } + + private function writeFlags(array $evaluation, ?int $performedBy): void + { + foreach ($evaluation['flags'] ?? [] as $flag) { + $this->upsertOpenFlag( + (int) $evaluation['student_id'], + (string) $evaluation['target_school_year'], + (string) $evaluation['source_school_year'], + $flag, + $performedBy + ); + } + } + + private function upsertOpenFlag( + int $studentId, + string $schoolYear, + string $sourceSchoolYear, + array $flag, + ?int $performedBy + ): bool { + if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) { + return false; + } + + $flagType = (string) ($flag['flag_type'] ?? ''); + if ($flagType === '') { + return false; + } + + $existing = $this->db->table('enrollment_flags') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('flag_type', $flagType) + ->where('status', 'open') + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + return false; + } + + $this->db->table('enrollment_flags')->insert([ + 'flag_type' => $flagType, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null, + 'status' => 'open', + 'priority' => $flag['priority'] ?? 'normal', + 'assigned_to' => $performedBy, + 'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES), + 'created_at' => date('Y-m-d H:i:s'), + ]); + + return true; + } + + private function activeBypassCodesByStudent(string $schoolYear): array + { + if ($schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) { + return []; + } + + $now = date('Y-m-d H:i:s'); + $rows = $this->db->table('enrollment_exceptions') + ->select('student_id, bypassed_rule_codes_json, reason_code') + ->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() + ->get() + ->getResultArray(); + + $byStudent = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + $codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true); + $codes = is_array($codes) ? array_values(array_filter(array_map('strval', $codes))) : []; + $reason = strtoupper(trim((string) ($row['reason_code'] ?? ''))); + if ($reason !== '') { + $codes[] = $reason; + } + $byStudent[$studentId] = array_values(array_unique(array_merge($byStudent[$studentId] ?? [], $codes))); + } + + return $byStudent; + } + + private function flagCoveredByException(string $flagType, array $bypassCodes): bool + { + if ($flagType === '' || $bypassCodes === []) { + return false; + } + + $bypassCodes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $bypassCodes); + $mapped = match ($flagType) { + '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'], + 'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'], + 'ADULT_STUDENT_ACTION_REQUIRED' => ['ADULT_STUDENT_PARENT_BLOCKED', 'ADULT_STUDENT_ACTION_REQUIRED'], + default => [$flagType], + }; + + return array_intersect($mapped, $bypassCodes) !== []; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function sourceYearStudents(string $sourceSchoolYear): array + { + $byId = []; + + if ($this->db->tableExists('student_class')) { + $rows = $this->db->table('student_class sc') + ->select('sc.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.school_year', $sourceSchoolYear) + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId > 0) { + $byId[$studentId] = $row; + } + } + } + + if ($this->db->tableExists('enrollments')) { + $rows = $this->db->table('enrollments e') + ->select('e.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name') + ->join('students s', 's.id = e.student_id', 'left') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.school_year', $sourceSchoolYear) + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId > 0 && ! isset($byId[$studentId])) { + $byId[$studentId] = $row; + } + } + } + + return array_values($byId); + } + + private function latestDecisionsByStudent(string $sourceSchoolYear): array + { + if (! $this->db->tableExists('student_decisions')) { + return []; + } + + $select = ['student_id', 'decision', 'source', 'notes', 'class_section_name', 'updated_at', 'id']; + if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $select[] = 'deliberation_decision_standard'; + } + + $rows = $this->db->table('student_decisions') + ->select($select) + ->where('school_year', $sourceSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $latest = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId > 0 && ! isset($latest[$studentId])) { + $latest[$studentId] = $row; + } + } + + return $latest; + } + + private function activeTargetEnrollmentStudentIds(string $targetSchoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + $select = ['student_id', 'enrollment_status', 'admission_status']; + if ($this->db->fieldExists('is_withdrawn', 'enrollments')) { + $select[] = 'is_withdrawn'; + } + + $rows = $this->db->table('enrollments') + ->select(implode(', ', $select)) + ->where('school_year', $targetSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $ids = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($ids[$studentId])) { + continue; + } + if ($this->activeEnrollmentBlocksDuplicate($row)) { + $ids[$studentId] = true; + } + } + + return $ids; + } + + private function syncSiblingLastNameFlags( + array $students, + array $enrolledIds, + array $bypassCodesByStudent, + string $targetSchoolYear, + string $sourceSchoolYear, + ?int $performedBy + ): int { + $parentIds = []; + $studentsByParent = []; + foreach ($students as $student) { + $parentId = (int) ($student['parent_id'] ?? 0); + $studentId = (int) ($student['student_id'] ?? 0); + if ($parentId <= 0 || $studentId <= 0) { + continue; + } + $parentIds[$parentId] = $parentId; + $studentsByParent[$parentId][] = $studentId; + } + if ($parentIds === [] || ! $this->db->tableExists('students')) { + return 0; + } + + $linked = $this->db->table('students') + ->select('id, parent_id, lastname') + ->whereIn('parent_id', array_values($parentIds)) + ->get() + ->getResultArray(); + + $namesByParent = []; + foreach ($linked as $row) { + $parentId = (int) ($row['parent_id'] ?? 0); + $namesByParent[$parentId][] = $this->normalizeLastName($row['lastname'] ?? null); + } + + $mismatchedParents = []; + foreach ($namesByParent as $parentId => $names) { + if (count($names) <= 1) { + continue; + } + $unique = array_values(array_unique($names)); + if (in_array('', $unique, true) || count($unique) > 1) { + $mismatchedParents[$parentId] = true; + } + } + + $written = 0; + foreach ($mismatchedParents as $parentId => $_) { + foreach ($studentsByParent[$parentId] ?? [] as $studentId) { + if (isset($enrolledIds[$studentId])) { + continue; + } + if ($this->flagCoveredByException('SIBLING_LAST_NAME_MISMATCH', $bypassCodesByStudent[$studentId] ?? [])) { + continue; + } + if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $this->flag('SIBLING_LAST_NAME_MISMATCH', 'high'), $performedBy)) { + $written++; + } + } + } + + return $written; + } + + private function syncFinancialFlags( + array $students, + array $enrolledIds, + array $bypassCodesByStudent, + string $targetSchoolYear, + string $sourceSchoolYear, + ?int $performedBy + ): int { + $target = $this->schoolYearByName($targetSchoolYear) ?? []; + $behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'); + if (! in_array($behavior, ['submission_blocked_until_payment', 'admin_approval_required'], true)) { + return 0; + } + if (! $this->db->tableExists('invoices')) { + return 0; + } + + $parentIds = []; + $studentsByParent = []; + foreach ($students as $student) { + $parentId = (int) ($student['parent_id'] ?? 0); + $studentId = (int) ($student['student_id'] ?? 0); + if ($parentId <= 0 || $studentId <= 0) { + continue; + } + $parentIds[$parentId] = $parentId; + $studentsByParent[$parentId][] = $studentId; + } + if ($parentIds === []) { + 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) { + continue; + } + $parentId = (int) ($row['parent_id'] ?? 0); + foreach ($studentsByParent[$parentId] ?? [] as $studentId) { + if (isset($enrolledIds[$studentId])) { + continue; + } + if ($this->flagCoveredByException($flagType, $bypassCodesByStudent[$studentId] ?? [])) { + continue; + } + if ($this->upsertOpenFlag( + $studentId, + $targetSchoolYear, + $sourceSchoolYear, + $this->flag($flagType, 'high', ['carry_over_balance' => (float) $row['balance']]), + $performedBy + )) { + $written++; + } + } + } + + return $written; + } + + private function appendFlagsFromRuleCodes(array &$evaluation): void + { + $existing = []; + foreach ($evaluation['flags'] ?? [] as $flag) { + $type = (string) ($flag['flag_type'] ?? ''); + if ($type !== '') { + $existing[$type] = true; + } + } + + $codes = array_values(array_unique(array_merge( + array_map('strval', $evaluation['blocking_rule_codes'] ?? []), + array_map('strval', $evaluation['review_rule_codes'] ?? []) + ))); + + $map = [ + 'NO_FINAL_DECISION' => ['DEFERRED_DELIBERATION', 'high'], + 'UNRECOGNIZED_DECISION' => ['DEFERRED_DELIBERATION', 'high'], + 'DEFERRED_DECISION' => ['DEFERRED_DELIBERATION', 'high'], + 'EXPELLED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'], + 'WITHDRAWN' => ['WITHDRAWAL_REVIEW_REQUIRED', 'normal'], + 'DENIED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'], + 'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'high'], + 'OUTSTANDING_BALANCE_BLOCKED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'], + 'FINANCE_APPROVAL_REQUIRED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'], + 'AGE_RULE_BLOCKED' => ['AGE_EXCEPTION_REQUIRED', 'normal'], + 'ADULT_STUDENT_PARENT_BLOCKED' => ['ADULT_STUDENT_ACTION_REQUIRED', 'normal'], + 'EXIT_REQUIRED' => ['COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal'], + 'REGISTRATION_CLOSED' => ['LATE_REGISTRATION_EXCEPTION', 'normal'], + ]; + + foreach ($codes as $code) { + if (! isset($map[$code])) { + continue; + } + [$type, $priority] = $map[$code]; + if (isset($existing[$type])) { + continue; + } + $evaluation['flags'][] = $this->flag($type, $priority, ['rule_code' => $code]); + $existing[$type] = true; } } @@ -616,6 +1168,18 @@ final class EnrollmentTransitionService return; } + $snapshot = [ + 'payload' => $new, + 'decision' => $evaluation['decision'] ?? null, + 'rule_codes' => $evaluation['rule_codes'] ?? [], + 'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [], + 'review_rule_codes' => $evaluation['review_rule_codes'] ?? [], + 'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [], + 'financial_summary' => $evaluation['financial_summary'] ?? null, + 'exception_id' => $evaluation['admin_exception']['id'] ?? null, + 'parent_id' => $evaluation['parent_id'] ?? ($new['parent_id'] ?? null), + ]; + $this->db->table('enrollment_transition_audits')->insert([ 'student_id' => (int) $evaluation['student_id'], 'school_year' => (string) $evaluation['target_school_year'], @@ -623,8 +1187,8 @@ final class EnrollmentTransitionService 'action' => $action, 'performed_by' => $performedBy, 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, - 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), - 'reason' => implode(' ', $evaluation['blockers'] ?? []), + 'new_values_json' => json_encode($snapshot, JSON_UNESCAPED_SLASHES), + 'reason' => implode(' ', $evaluation['blockers'] ?? []) ?: implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])), 'created_at' => date('Y-m-d H:i:s'), ]); } @@ -663,4 +1227,493 @@ final class EnrollmentTransitionService $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); return $name !== '' ? $name : 'The student'; } + + private function ensureDecisionFields(array &$evaluation): void + { + $evaluation['rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['rule_codes'] ?? []))); + $evaluation['blocking_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? []))); + $evaluation['warning_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['warning_rule_codes'] ?? []))); + $evaluation['review_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? []))); + $evaluation['admin_exception'] = $evaluation['admin_exception'] ?? null; + $evaluation['decision'] = $evaluation['decision'] ?? self::DECISION_REVIEW_REQUIRED; + $evaluation['can_enroll'] = (bool) ($evaluation['parent_enrollment_allowed'] ?? false); + + foreach ($evaluation['blockers'] ?? [] as $message) { + $this->addDerivedBlockerCode($evaluation, (string) $message); + } + + foreach ($evaluation['warnings'] ?? [] as $message) { + $this->addRuleCode($evaluation, $this->ruleCodeFromMessage((string) $message), 'warning'); + } + } + + private function deriveAcademicRuleCodes(array &$evaluation): void + { + $decision = (string) ($evaluation['deliberation_decision'] ?? ''); + if (in_array($decision, [ + DeliberationDecision::PASSED, + DeliberationDecision::REPEAT_CLASS, + DeliberationDecision::MAKE_UP_EXAM, + ], true)) { + $this->addRuleCode($evaluation, $decision, $decision === DeliberationDecision::MAKE_UP_EXAM ? 'warning' : null); + } + + if (($evaluation['placement_status'] ?? '') === 'exit_required') { + $this->addRuleCode($evaluation, 'EXIT_REQUIRED', 'blocking'); + } + + foreach ($evaluation['flags'] ?? [] as $flag) { + $flagType = (string) ($flag['flag_type'] ?? ''); + if ($flagType !== '') { + $this->addRuleCode($evaluation, $flagType, 'warning'); + } + } + } + + private function addDerivedBlockerCode(array &$evaluation, string $message): void + { + $code = $this->ruleCodeFromMessage($message); + $this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking'); + } + + private function ruleCodeFromMessage(string $message): string + { + $lower = strtolower($message); + + return match (true) { + str_contains($lower, 'expelled') => 'EXPELLED', + str_contains($lower, 'withdrawn') => 'WITHDRAWN', + 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, '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', + str_contains($lower, 'closing school year') => 'SOURCE_YEAR_NOT_FOUND', + str_contains($lower, 'target school year') => 'TARGET_YEAR_NOT_FOUND', + str_contains($lower, 'highest available grade') || str_contains($lower, 'exit process') => 'EXIT_REQUIRED', + default => 'REVIEW_REQUIRED', + }; + } + + private function reviewCode(string $code): bool + { + return in_array($code, [ + 'NO_FINAL_DECISION', + 'UNRECOGNIZED_DECISION', + 'DEFERRED_DECISION', + 'REVIEW_REQUIRED', + ], true); + } + + private function addBlocker(array &$evaluation, string $code, string $message): void + { + $blockers = array_values(array_filter(array_map('strval', $evaluation['blockers'] ?? []))); + if (! in_array($message, $blockers, true)) { + $blockers[] = $message; + } + $evaluation['blockers'] = $blockers; + $this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking'); + $evaluation['parent_enrollment_allowed'] = false; + $evaluation['can_enroll'] = false; + } + + private function addRuleCode(array &$evaluation, string $code, ?string $bucket = null): void + { + $code = strtoupper(trim($code)); + if ($code === '') { + return; + } + + $evaluation['rule_codes'] = array_values(array_unique(array_merge($evaluation['rule_codes'] ?? [], [$code]))); + if ($bucket !== null) { + $field = $bucket . '_rule_codes'; + $evaluation[$field] = array_values(array_unique(array_merge($evaluation[$field] ?? [], [$code]))); + } + } + + private function removeRuleCode(array &$evaluation, string $code): void + { + $code = strtoupper(trim($code)); + if ($code === '') { + return; + } + + foreach (['rule_codes', 'blocking_rule_codes', 'warning_rule_codes', 'review_rule_codes'] as $field) { + $evaluation[$field] = array_values(array_filter( + array_map('strval', $evaluation[$field] ?? []), + static fn (string $existing): bool => $existing !== $code + )); + } + + $lastNameMessages = [ + 'Linked student last-name data must be reviewed by administration before re-enrollment.', + 'Linked siblings have different last names. Please contact administration to review the family record.', + ]; + $evaluation['blockers'] = array_values(array_filter( + array_map('strval', $evaluation['blockers'] ?? []), + static fn (string $message): bool => ! in_array($message, $lastNameMessages, true) + )); + } + + private function activeEnrollmentBlocksDuplicate(?array $enrollment): bool + { + if ($enrollment === null) { + return false; + } + + if ((int) ($enrollment['is_withdrawn'] ?? 0) === 1) { + return false; + } + + $status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? ''))); + $admission = strtolower(trim((string) ($enrollment['admission_status'] ?? ''))); + + return $admission === 'accepted' || in_array($status, [ + 'admission under review', + 'review & decision', + 'payment pending', + 'enrolled', + 'waitlist', + 'withdraw under review', + 'refund pending', + ], true); + } + + private function deniedOrWithdrawnEnrollmentBlocksStandardEligibility(?array $enrollment): bool + { + if ($enrollment === null) { + return false; + } + + $status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? ''))); + $admission = strtolower(trim((string) ($enrollment['admission_status'] ?? ''))); + + return $admission === 'denied' + || in_array($status, ['denied', 'withdrawn', 'widthran'], true) + || (int) ($enrollment['is_withdrawn'] ?? 0) === 1; + } + + private function applyHouseholdLastNameRule(array &$evaluation, int $parentId): void + { + $students = $this->linkedStudentsForParent($parentId); + if (count($students) <= 1) { + $evaluation['family_name_check_ok'] = true; + return; + } + + $normalized = []; + foreach ($students as $student) { + $lastName = $this->normalizeLastName($student['lastname'] ?? null); + if ($lastName === '') { + $this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked student last-name data must be reviewed by administration before re-enrollment.'); + $evaluation['family_name_check_ok'] = false; + return; + } + $normalized[$lastName] = true; + } + + $ok = count($normalized) === 1; + $evaluation['family_name_check_ok'] = $ok; + if (! $ok) { + $this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked siblings have different last names. Please contact administration to review the family record.'); + } + } + + private function applyCarriedForwardLastNameException( + array &$evaluation, + int $parentId, + int $studentId, + string $sourceSchoolYear, + string $targetSchoolYear + ): void { + if (! in_array('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes'] ?? [], true)) { + return; + } + + $financialBlockers = array_values(array_intersect( + $evaluation['blocking_rule_codes'] ?? [], + ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'] + )); + if ($financialBlockers !== []) { + return; + } + + $prior = $this->priorLastNameException($parentId, $studentId, $targetSchoolYear, $sourceSchoolYear); + if ($prior === null) { + return; + } + + $this->removeRuleCode($evaluation, 'SIBLING_LAST_NAME_MISMATCH'); + $this->addRuleCode($evaluation, 'LAST_NAME_EXCEPTION_CARRIED_FORWARD', 'warning'); + $evaluation['warnings'] = array_values(array_unique(array_merge( + array_map('strval', $evaluation['warnings'] ?? []), + ['A previous last-name exception still applies for this student.'] + ))); + $evaluation['family_name_check_ok'] = true; + $evaluation['last_name_exception_carry_forward'] = [ + 'eligible' => true, + 'prior_exception_id' => (int) ($prior['id'] ?? 0), + 'prior_school_year' => (string) ($prior['school_year'] ?? ''), + 'approved_student_ids' => [(int) ($prior['student_id'] ?? $studentId)], + ]; + } + + public function linkedStudentIds(int $parentId): array + { + return array_values(array_unique(array_filter(array_map( + static fn (array $student): int => (int) ($student['id'] ?? 0), + $this->linkedStudentsForParent($parentId) + ), static fn (int $id): bool => $id > 0))); + } + + private function priorLastNameException(int $parentId, int $studentId, string $targetSchoolYear, string $sourceSchoolYear): ?array + { + if ($parentId <= 0 || $studentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { + return null; + } + + $rows = $this->db->table('enrollment_exceptions') + ->where('parent_id', $parentId) + ->where('student_id', $studentId) + ->whereIn('status', ['active', 'used']) + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $sourceMatch = null; + $otherPrior = null; + foreach ($rows as $row) { + if ((int) ($row['student_id'] ?? 0) !== $studentId) { + continue; + } + $year = (string) ($row['school_year'] ?? ''); + if ($year === '' || $year === $targetSchoolYear || ! $this->exceptionBypassesLastName($row)) { + continue; + } + + if ($year === $sourceSchoolYear && $sourceMatch === null) { + $sourceMatch = $row; + } elseif ($otherPrior === null) { + $otherPrior = $row; + } + } + + return $sourceMatch ?? $otherPrior; + } + + private function exceptionBypassesLastName(array $exception): bool + { + $reason = strtoupper(trim((string) ($exception['reason_code'] ?? ''))); + if (in_array($reason, ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'], true) + || str_contains($reason, 'LAST_NAME') + ) { + return true; + } + + $codes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true); + if (! is_array($codes)) { + return false; + } + + $codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes); + + return in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true); + } + + private function linkedStudentsForParent(int $parentId): array + { + if ($parentId <= 0 || ! $this->db->tableExists('students')) { + return []; + } + + return $this->db->table('students') + ->select('id, firstname, lastname, parent_id') + ->where('parent_id', $parentId) + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function normalizeLastName(mixed $value): string + { + $value = strtolower(trim((string) $value)); + $value = str_replace(['’', '`', '´'], "'", $value); + $value = preg_replace('/[^\p{L}\p{N}\s]+/u', '', $value) ?? ''; + $value = preg_replace('/\s+/u', ' ', $value) ?? ''; + + return trim($value); + } + + private function applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void + { + $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.'); + } + + private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): 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); + + return [ + 'currency' => '$', + 'source_school_year' => $sourceSchoolYear, + 'target_school_year' => $targetSchoolYear, + 'carry_over_balance' => round($carryOver, 2), + 'current_balance' => round($current, 2), + 'registration_fee' => $registrationFee, + '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'), + 'evaluated_at' => date('Y-m-d H:i:s'), + ]; + } + + private function invoiceBalanceForParent(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) + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return round((float) ($row['balance'] ?? 0), 2); + } + + private function applyScopedException(array &$evaluation, int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear): void + { + $blockingCodes = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? []))); + $reviewCodes = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? []))); + $failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes))); + if ($failedCodes === []) { + return; + } + + $nonOverridable = array_values(array_intersect($failedCodes, self::NON_OVERRIDABLE_RULE_CODES)); + if ($nonOverridable !== []) { + 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); + 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; + } + + $evaluation['admin_exception'] = [ + 'id' => (int) $exception['id'], + 'reason_code' => (string) ($exception['reason_code'] ?? ''), + 'source_school_year' => $sourceSchoolYear, + 'bypassed_rule_codes' => $failedCodes, + ]; + $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 + { + 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') + ->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() + ->orderBy('created_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function finalizeParentDecision(array &$evaluation): void + { + if (($evaluation['admin_exception'] ?? null) !== null && ! in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) { + $evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE; + $evaluation['parent_enrollment_allowed'] = true; + $evaluation['can_enroll'] = true; + return; + } + + if (in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) { + $evaluation['decision'] = self::DECISION_ALREADY_ENROLLED; + $evaluation['parent_enrollment_allowed'] = false; + $evaluation['can_enroll'] = false; + return; + } + + if (($evaluation['blocking_rule_codes'] ?? []) !== []) { + $evaluation['decision'] = self::DECISION_INELIGIBLE; + $evaluation['parent_enrollment_allowed'] = false; + $evaluation['can_enroll'] = false; + return; + } + + if (($evaluation['review_rule_codes'] ?? []) !== []) { + $evaluation['decision'] = self::DECISION_REVIEW_REQUIRED; + $evaluation['parent_enrollment_allowed'] = false; + $evaluation['can_enroll'] = false; + return; + } + + if (! empty($evaluation['warnings']) || ($evaluation['warning_rule_codes'] ?? []) !== []) { + $evaluation['decision'] = self::DECISION_ELIGIBLE_WITH_WARNING; + } else { + $evaluation['decision'] = self::DECISION_ELIGIBLE; + } + + $evaluation['parent_enrollment_allowed'] = true; + $evaluation['can_enroll'] = true; + } } diff --git a/app/Services/FeeCalculationService.php b/app/Services/FeeCalculationService.php index 876ee8d..c4ce3b4 100644 --- a/app/Services/FeeCalculationService.php +++ b/app/Services/FeeCalculationService.php @@ -59,21 +59,15 @@ class FeeCalculationService }); // Retrieve fee configs - $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); - $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); - $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200); + $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380); + $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280); // Assign tuition_fee to all students (before filtering refunds) - $regularCount = 0; + $studentCount = 0; foreach ($allStudents as &$student) { - $gradeLevel = $this->getGradeLevel($student['grade']); - - if ($gradeLevel > 9) { - $studentFee = $youthFee; - } else { - $studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee; - $regularCount++; - } + $studentFee = ($studentCount === 0) ? $firstStudentFee : $secondStudentFee; + $studentCount++; + $student['tuition_fee'] = $studentFee; } unset($student); @@ -97,7 +91,7 @@ class FeeCalculationService $daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days; $weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7))); - //$studentFee = $student['tuition_fee']; + $studentFee = (float) ($student['tuition_fee'] ?? 0); $proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining; $refundAmount += $proportionalRefund; @@ -146,9 +140,8 @@ class FeeCalculationService $configModel = new ConfigurationModel(); $classSectionModel = new \App\Models\ClassSectionModel(); - $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); - $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); - $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200); + $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380); + $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280); // ✅ Pre-fetch and assign grade/class section names before sorting foreach ($students as &$student) { @@ -162,19 +155,13 @@ class FeeCalculationService return $this->compareGrades($a['grade'], $b['grade']); }); - $regularCount = 0; + $studentCount = 0; $totalFee = 0; // ✅ Calculate fee foreach ($students as $student) { - $gradeLevel = $this->getGradeLevel($student['grade']); - - if ($gradeLevel > 9) { - $totalFee += $youthFee; - } else { - $totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee; - $regularCount++; - } + $totalFee += ($studentCount === 0) ? $firstStudentFee : $secondStudentFee; + $studentCount++; } return $totalFee; diff --git a/app/Services/FinancialAidService.php b/app/Services/FinancialAidService.php new file mode 100644 index 0000000..b7ba2c2 --- /dev/null +++ b/app/Services/FinancialAidService.php @@ -0,0 +1,300 @@ +latestInvoice($parentId, $schoolYear); + if ($invoice === null) { + $invoice = $this->createInvoiceForParentYear($parentId, $schoolYear); + } + + $db = $this->requestModel->db; + $db->transStart(); + + $code = 'FA-' . (int) ($request['id'] ?? 0) . '-' . date('YmdHis'); + $voucherId = $this->voucherModel->insert([ + 'code' => $code, + 'discount_type' => 'fixed', + 'discount_value' => $amount, + 'max_uses' => 1, + 'times_used' => 0, + 'valid_from' => date('Y-m-d'), + 'valid_until' => date('Y-m-d', strtotime('+1 year')), + 'school_year' => $schoolYear, + 'is_active' => 1, + 'description' => 'Financial aid request #' . (int) ($request['id'] ?? 0), + ], true); + + if ($voucherId === false) { + $db->transRollback(); + throw new RuntimeException('Unable to create the financial aid voucher.'); + } + + $now = function_exists('utc_now') ? utc_now() : date('Y-m-d H:i:s'); + $amountCents = (int) round($amount * 100); + $usagePayload = [ + 'voucher_id' => (int) $voucherId, + 'invoice_id' => (int) $invoice['id'], + 'parent_id' => $parentId, + 'discount_amount' => $amount, + 'description' => 'Financial aid', + 'school_year' => $schoolYear, + 'updated_by' => $reviewedBy, + 'used_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if ($db->fieldExists('requested_discount_cents', 'discount_usages')) { + $usagePayload['requested_discount_cents'] = $amountCents; + $usagePayload['eligible_base_cents'] = $amountCents; + $usagePayload['eligible_base_before_cents'] = $amountCents; + $usagePayload['applied_discount_cents'] = $amountCents; + $usagePayload['application_order'] = 1; + } + + $usageId = $this->usageModel->insert($usagePayload, true); + if ($usageId === false) { + $db->transRollback(); + throw new RuntimeException('Unable to record the financial aid discount.'); + } + + $this->voucherModel->update((int) $voucherId, ['times_used' => 1, 'is_active' => 0]); + $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']); + + $this->requestModel->update((int) $request['id'], [ + 'status' => 'approved', + 'admin_amount' => $amount, + 'admin_note' => $adminNote, + 'reviewed_by' => $reviewedBy, + 'reviewed_at' => $now, + 'invoice_id' => (int) $invoice['id'], + 'discount_usage_id' => (int) $usageId, + 'voucher_id' => (int) $voucherId, + ]); + + $db->transComplete(); + if ($db->transStatus() === false) { + throw new RuntimeException('Unable to apply the financial aid discount.'); + } + + return $this->requestModel->find((int) $request['id']) ?? $request; + } + + private function latestInvoice(int $parentId, string $schoolYear): ?array + { + if ($parentId <= 0 || $schoolYear === '') { + return null; + } + + return $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->first(); + } + + private function createInvoiceForParentYear(int $parentId, string $schoolYear): array + { + if ($parentId <= 0 || $schoolYear === '') { + throw new RuntimeException('Cannot create an invoice because the parent or school year is missing.'); + } + + $enrollmentModel = $this->enrollmentModel ?? new EnrollmentModel(); + $studentClassModel = $this->studentClassModel ?? new StudentClassModel(); + $classSectionModel = $this->classSectionModel ?? new ClassSectionModel(); + $eventChargesModel = $this->eventChargesModel ?? new EventChargesModel(); + $configurationModel = $this->configurationModel ?? new ConfigurationModel(); + $userModel = $this->userModel ?? new UserModel(); + $invoiceIssuanceService = $this->invoiceIssuanceService ?? new InvoiceIssuanceService( + $this->requestModel->db, + $this->invoiceModel, + null, + $this->invoiceLedgerService + ); + + $semester = (string) ($configurationModel->getConfig('semester') ?: ''); + $enrollments = $enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->findAll(); + + if ($enrollments === []) { + throw new RuntimeException('No enrollment records were found, so an invoice could not be created for this parent.'); + } + + $registeredKids = []; + $withdrawnKids = []; + foreach ($enrollments as $enrollment) { + $studentData = [ + 'student_id' => (int) ($enrollment['student_id'] ?? 0), + 'parent_id' => (int) ($enrollment['parent_id'] ?? 0), + 'class_section_id' => (int) ($enrollment['class_section_id'] ?? 0), + 'enrollment_status' => (string) ($enrollment['enrollment_status'] ?? ''), + 'school_year' => (string) ($enrollment['school_year'] ?? ''), + 'semester' => (string) ($enrollment['semester'] ?? ''), + 'admission_status' => (string) ($enrollment['admission_status'] ?? ''), + 'is_withdrawn' => (int) ($enrollment['is_withdrawn'] ?? 0), + ]; + + if (in_array($studentData['enrollment_status'], ['enrolled', 'payment pending'], true)) { + $registeredKids[] = $studentData; + } elseif (in_array($studentData['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) { + $withdrawnKids[] = $studentData; + } + } + + $registeredKids = $this->onlyStudentsWithClassAssignment($registeredKids, $studentClassModel, $schoolYear); + $withdrawnKids = $this->onlyStudentsWithClassAssignment($withdrawnKids, $studentClassModel, $schoolYear); + $tuitionAmount = $this->calculateTuitionAmount($registeredKids, $withdrawnKids, $classSectionModel, $configurationModel); + $eventAmount = array_sum(array_map( + static fn(array $row): float => (float) ($row['charged'] ?? 0), + $eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) + )); + $totalAmount = $tuitionAmount + $eventAmount; + + if ($totalAmount <= 0) { + throw new RuntimeException('Invoice could not be created because this parent has no billable tuition or event charges.'); + } + + $schoolId = $userModel->getSchoolIdByUserId($parentId); + $invoiceNumber = !empty($schoolId) + ? 'INV-' . $schoolId . '-' . uniqid() + : uniqid('INV-'); + $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + $dueUtc = $this->invoiceDueUtc($configurationModel); + + $result = $invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([ + 'parent_id' => $parentId, + 'invoice_number' => $invoiceNumber, + 'total_amount' => $totalAmount, + 'paid_amount' => 0, + 'balance' => $totalAmount, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'issue_date' => $issueUtc, + 'due_date' => $dueUtc, + 'created_at' => function_exists('utc_now') ? utc_now() : $issueUtc, + 'updated_at' => function_exists('utc_now') ? utc_now() : $issueUtc, + ], $tuitionAmount, $eventAmount, [ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'registered_student_count' => count($registeredKids), + 'withdrawn_student_count' => count($withdrawnKids), + ])); + + $invoice = $this->invoiceModel->find($result->invoiceId); + if (!is_array($invoice)) { + throw new RuntimeException('Invoice was created but could not be reloaded.'); + } + + return $invoice; + } + + private function onlyStudentsWithClassAssignment(array $students, StudentClassModel $studentClassModel, string $schoolYear): array + { + return array_values(array_filter($students, static function (array $student) use ($studentClassModel, $schoolYear): bool { + $studentId = (int) ($student['student_id'] ?? 0); + return $studentId > 0 && $studentClassModel->hasNonEventAssignment($studentId, $schoolYear); + })); + } + + private function calculateTuitionAmount( + array $registeredKids, + array $withdrawnKids, + ClassSectionModel $classSectionModel, + ConfigurationModel $configurationModel + ): float { + $tuitionStudents = $this->isBeforeRefundDeadline($configurationModel) + ? $registeredKids + : array_merge($registeredKids, $withdrawnKids); + + foreach ($tuitionStudents as &$student) { + $gradeName = $classSectionModel->getClassSectionNameBySectionId((int) ($student['class_section_id'] ?? 0)); + $student['grade'] = strtoupper(trim((string) $gradeName)); + } + unset($student); + + usort($tuitionStudents, static fn(array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null)); + + $firstStudentFee = (float) ($configurationModel->getConfig('first_student_fee') ?? 380); + $secondStudentFee = (float) ($configurationModel->getConfig('second_student_fee') ?? 280); + $total = 0.0; + foreach (array_values($tuitionStudents) as $index => $student) { + $total += $index === 0 ? $firstStudentFee : $secondStudentFee; + } + + return $total; + } + + private function isBeforeRefundDeadline(ConfigurationModel $configurationModel): bool + { + try { + $refundDeadline = (string) ($configurationModel->getConfig('refund_deadline') ?? ''); + if ($refundDeadline === '') { + return true; + } + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $tz = new DateTimeZone($tzName); + return new \DateTimeImmutable('today', $tz) <= new \DateTimeImmutable($refundDeadline, $tz); + } catch (\Throwable) { + return true; + } + } + + private function invoiceDueUtc(ConfigurationModel $configurationModel): ?string + { + $dueDate = (string) ($configurationModel->getConfig('first_day_of_school') ?: $configurationModel->getConfig('due_date') ?: ''); + if ($dueDate === '') { + return null; + } + + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($tzName)); + $dueLocal->setTimezone(new DateTimeZone('UTC')); + return $dueLocal->format('Y-m-d H:i:s'); + } +} diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 86ead1e..f951496 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -5,7 +5,6 @@ namespace App\Services; use App\Models\SchoolYearClosingBatchModel; use App\Models\SchoolYearClosingItemModel; use App\Models\SchoolYearModel; -use App\Models\ConfigurationModel; use App\Models\InvoiceModel; use App\Support\Enrollment\DeliberationDecision; use App\Support\SchoolYear\SchoolYearStatus; @@ -19,7 +18,6 @@ final class SchoolYearClosingService private readonly SchoolYearModel $schoolYearModel, private readonly SchoolYearClosingBatchModel $batchModel, private readonly SchoolYearClosingItemModel $itemModel, - private readonly ConfigurationModel $configurationModel, private readonly SchoolYearManagementService $managementService, private readonly BaseConnection $db, ) { @@ -261,7 +259,7 @@ final class SchoolYearClosingService 'next_school_year_id' => $targetYearId, ]); $targetName = (string) ($target['name'] ?? ''); - $this->configurationModel->setConfigValueByKey('school_year', $targetName); + $this->managementService->syncConfigurationForYear($targetYearId); $this->syncActiveYearSession($targetName); $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [ 'closing_batch_id' => (int) $batch['id'], diff --git a/app/Services/SchoolYearManagementService.php b/app/Services/SchoolYearManagementService.php index 6200909..6c4305d 100644 --- a/app/Services/SchoolYearManagementService.php +++ b/app/Services/SchoolYearManagementService.php @@ -8,6 +8,7 @@ use App\Models\SchoolYearModel; use App\Models\SchoolYearTransitionLogModel; use App\Support\SchoolYear\SchoolYearStatus; use CodeIgniter\Database\BaseConnection; +use DateTimeImmutable; use InvalidArgumentException; use RuntimeException; @@ -25,15 +26,29 @@ final class SchoolYearManagementService public function createDraft(array $payload, ?int $userId = null): int { + $existingYears = $this->db->table($this->schoolYearModel->getTable())->countAllResults(); $nextDraft = $this->nextDraftDefaults(); - if ($nextDraft['name'] === null) { - throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.'); + $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['name'] = $nextDraft['name']; + $payload = $this->withCalendarDefaults($payload, (string) $payload['name']); $payload = $this->metadataPayload($payload); - $payload['previous_school_year_id'] = (int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name']); + $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; @@ -42,7 +57,9 @@ final class SchoolYearManagementService $this->db->transStart(); $id = $this->schoolYearModel->insert($payload, true); if ($id !== false) { - $this->syncConfigurationFromSchoolYear($payload); + if ($existingYears === 0 || $this->schoolYearModel->active() === null) { + $this->syncConfigurationFromSchoolYear($payload); + } $this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId); } $this->db->transComplete(); @@ -54,6 +71,49 @@ final class SchoolYearManagementService 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(); @@ -83,7 +143,9 @@ final class SchoolYearManagementService $this->db->transStart(); $updated = $this->schoolYearModel->update($id, $payload); if ($updated !== false) { - $this->syncConfigurationFromSchoolYear($payload); + if ($status === SchoolYearStatus::ACTIVE) { + $this->syncConfigurationFromSchoolYear(array_merge($year, $payload)); + } $this->log($id, $status, $status, 'metadata_update', $userId); } $this->db->transComplete(); @@ -121,10 +183,13 @@ final class SchoolYearManagementService ]); } + $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'], ]); $this->syncConfigurationFromSchoolYear($year); $this->syncActiveYearSession((string) $year['name']); @@ -137,6 +202,11 @@ final class SchoolYearManagementService } } + public function syncConfigurationForYear(int $id): void + { + $this->syncConfigurationFromSchoolYear($this->requireYear($id)); + } + public function deleteDraft(int $id, ?int $userId = null): void { $year = $this->requireYear($id); @@ -289,14 +359,54 @@ final class SchoolYearManagementService 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, 'date_age_reference' => $ageReferenceDate, 'refund_deadline' => $ageReferenceDate, - 'enrollment_deadline' => (string) ($schoolYear['registration_ends_on'] ?? ''), - 'fall_semester_start' => (string) ($schoolYear['starts_on'] ?? ''), - 'last_day_of_school' => (string) ($schoolYear['ends_on'] ?? ''), + 'year_start_date' => $yearStart, + 'year_end_date' => $yearEnd, + 'school_year_start_date' => $yearStart, + 'school_year_end_date' => $yearEnd, + 'registration_day' => $registrationDay, + 'registration_starts_on' => $registrationDay, + 'end_of_registration' => $enrollmentDeadline, + 'enrollment_deadline' => $enrollmentDeadline, + '1st_day_of_school' => $firstDay, + 'first_day_of_school' => $firstDay, + 'Installment_date' => $installment, + 'installment_date' => $installment, + 'fall_semester_start' => $firstDay, + 'school_start_date' => $firstDay, + 'Due_date' => $firstDay, + 'due_date' => $firstDay, + 'last_day_of_school' => $lastDay, + 'last_school_day' => $lastDay, + 'Final_Exam_day' => $finalExam, + 'final_exam_day' => $finalExam, + 'Make_up_exam' => $makeupExam, + '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) { @@ -306,6 +416,76 @@ final class SchoolYearManagementService } } + 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'), + '1st_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'), + 'last_school_day' => $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)) { diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index d8888fd..359c05b 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -1,12 +1,223 @@ = $this->extend('layout/management_layout') ?> -= $this->section('content') ?> += $this->section('styles') ?> + += $this->endSection() ?> -
Work these steps in order: clear today's queue, grant any family exceptions, then send parent emails.
Start here. These are students who currently cannot complete re-enrollment, plus makeup and class follow-ups.
+| Student | +School ID | +Issue | +Priority | +Details | +Assigned | +Created | +What to do | +
|---|---|---|---|---|---|---|---|
| = esc($flag['student_name'] ?? '') ?> | += esc($flag['school_id'] ?? '') ?> | += esc(enrollment_admin_flag_label($flagTypeValue)) ?> | += esc(enrollment_admin_priority_label($priorityValue)) ?> | +
+
+ $value): ?>
+ = esc(ucwords(str_replace('_', ' ', (string) $key))) ?>: = esc(is_scalar($value) ? (string) $value : json_encode($value)) ?>
+
+
+ No extra details
+
+ |
+ = esc($flag['assignee_name'] ?: '') ?> | += esc(!empty($flag['created_at']) ? local_datetime($flag['created_at'], 'm-d-Y H:i') : '') ?> | +
+
+
+
+
+
+
+
+
+
+
+ = esc($flag['resolution_notes'] ?? '') ?>
+
+ |
+
| Parent | -Recipient | -Students | -Subject | -Delivery | -Example | -||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| = esc($example['parent_name'] ?? '') ?> | -= esc(implode(', ', $example['recipients'] ?? [])) ?> | -= esc(implode(', ', $example['student_names'] ?? [])) ?> | -= esc($example['subject'] ?? '') ?> | -
- = esc($deliveryStatus) ?>
-
- = esc(local_datetime($example['sent_at'], 'm-d-Y H:i')) ?>
-
-
- = esc($example['failure_reason']) ?>
-
- |
- - View Example - | +Student | +School ID | +Enrollment status | +Decision | +Placement | +Class | +Exception / reason | +Updated |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| No parent decision email examples found for this school year. | -|||||||||||||
Use the lookup to grant a family exception after reviewing the matching work queue item. Saved exceptions stay here for audit and revocation.
-| Student | -School ID | -Enrollment Status | -Decision | -Placement | -Class | -Exception / Reason | -Updated | -
|---|---|---|---|---|---|---|---|
| = esc($row['student_name'] ?? '') ?> | -= esc($row['school_id'] ?? '') ?> | -= esc($statusValue) ?> | -= esc($decisionValue !== '' ? $decisionValue : 'Pending') ?> | -= esc($placementValue !== '' ? $placementValue : 'Pending') ?> | -= esc($row['class_section_name'] ?? '') ?> | -
-
- Exception
-
- = esc($row['exception_reason'] ?? '') ?>
- |
- = esc(!empty($row['updated_at']) ? local_datetime($row['updated_at'], 'm-d-Y H:i') : '') ?> | -
| No enrollment records need follow-up for this school year. | -|||||||
| Student | -School ID | -Flag Type | -Priority | -Details | -Assigned | -Created | -Action | -
|---|---|---|---|---|---|---|---|
| = esc($flag['student_name'] ?? '') ?> | -= esc($flag['school_id'] ?? '') ?> | -= esc($flagTypeValue) ?> | -= esc($flag['priority'] ?? 'normal') ?> | -
-
- $value): ?>
- = esc((string) $key) ?>: = esc(is_scalar($value) ? (string) $value : json_encode($value)) ?>
-
-
- No details
-
- |
- = esc($flag['assignee_name'] ?: '') ?> | -= esc(!empty($flag['created_at']) ? local_datetime($flag['created_at'], 'm-d-Y H:i') : '') ?> | -
-
-
-
-
-
-
-
-
+
+ = esc($preview['error']) ?>
+
+
+
-
- = esc($preview['parent_name'] ?? '') ?>
+
+ = esc(($preview['source_school_year'] ?? '') . ' to ' . ($preview['school_year'] ?? '')) ?>
+
+ · = esc($preview['parent_email']) ?>
+
+
+ = esc($flag['resolution_notes'] ?? '') ?>
+ Select a parent to see their students and whether they can enroll.
- |
-
| No formal enrollment flags found. Check Enrollment Follow-up above for status-based items. | -|||||||
| When | -Student | -Action | -Administrator | -Reason | -||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Student | +Parent | +Status | +Reason | +Bypassed rules | +Created by | +Expires | +Action | +
|---|---|---|---|---|---|---|---|
|
+ = esc($exception['student_name'] ?? '') ?>
+ = esc($exception['school_id'] ?? '') ?>
+ |
+ = esc($exception['parent_name'] ?? '') ?> | += esc(enrollment_admin_exception_status_label($exceptionStatus)) ?> | +
+ = esc(enrollment_admin_reason_label((string) ($exception['reason_code'] ?? ''), $reasonCodes)) ?>
+ = esc($exception['reason_note'] ?? '') ?>
+ |
+ = esc($bypassedLabels !== [] ? implode(', ', $bypassedLabels) : 'None') ?> | += esc($exception['created_by_name'] ?? '') ?> | += esc(!empty($exception['expires_at']) ? local_datetime($exception['expires_at'], 'm-d-Y H:i') : 'No expiry') ?> | +
+
+
+
+ = esc($exception['revocation_reason']) ?>
+
+ -
+
+ |
+
Preview one email, approve the launch, then send. Do this after the work queue and exceptions are in good shape.
+ + +Open one parent email and confirm the wording, dates, and fees look right.
+ + Preview sample + + No parent with linked students is available for a sample preview. + +Approved = esc(local_datetime($launchState['approved_at'], 'm-d-Y H:i')) ?>.
+ +Approve only after the year dates and fees are correct.
+ + +This emails parents for = esc((string) ($schoolYear ?? '')) ?>. Use resend only if you need to send again.
+ +| = esc(!empty($row['created_at']) ? local_datetime($row['created_at'], 'm-d-Y H:i') : '') ?> | -= esc($row['student_name'] ?? '') ?> | -= esc($row['action'] ?? '') ?> | -= esc($row['performed_by_name'] ?? '') ?> | -= esc($row['reason'] ?? '') ?> | +Parent | +Recipient | +Students | +Subject | +Delivery | +Example |
|---|---|---|---|---|---|---|---|---|---|---|
| No enrollment audit records found. Audit rows appear after actions are completed from this dashboard or through the transition service. | -||||||||||
A record of enrollment decisions and exception actions for this school year.
+| When | +Student | +Action | +Administrator | +Reason | +
|---|---|---|---|---|
| = esc(!empty($row['created_at']) ? local_datetime($row['created_at'], 'm-d-Y H:i') : '') ?> | += esc($row['student_name'] ?? '') ?> | += esc(enrollment_admin_audit_action_label((string) ($row['action'] ?? ''))) ?> | += esc($row['performed_by_name'] ?? '') ?> | += esc($row['reason'] ?? '') ?> | +
| ID | +Parent | +Year | +Status | +Requested | +Submitted | ++ |
|---|---|---|---|---|---|---|
| = (int) ($row['id'] ?? 0) ?> | += esc(trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: ('#' . (int) ($row['parent_id'] ?? 0))) ?> | += esc($row['school_year'] ?? '') ?> | += esc($row['status'] ?? '') ?> | += $row['requested_amount'] !== null && $row['requested_amount'] !== '' ? '$' . number_format((float) $row['requested_amount'], 2) : '—' ?> | += esc($row['created_at'] ?? '') ?> | +Review | +
| No financial aid requests found. | ||||||
= nl2br(esc($requestRow['need_statement'] ?? '')) ?>
+Enrollment is the process of officially signing up your child for the upcoming school year.
-No students found for the selected school year. Please register your kids first.
+