# School-Year-Aware Code Update Plan for CodeIgniter 4

## Goal

Update the CodeIgniter 4 application so school-year filtering is applied only to records that are logically owned by a school year.

The application must stop assuming that every table containing a `school_year` column should be filtered by it. Some tables are year-owned, some are global, some contain global identities with year-specific relationship records, and some contain optional school-year context.

Required behavior:

- Active-year pages display only active-year records.
- A selected historical year can be viewed intentionally.
- Closed or archived years are read-only unless an explicit administrative operation allows changes.
- Authentication, authorization, configuration, identity, and infrastructure tables remain global.
- Finance, attendance, exams, events, progress, and reports never mix records from different years.
- Missing required school-year context causes a clear error instead of returning unfiltered records.
- The backend validates all school-year parameters. Query parameters are input, not truth.

---

## Current Problem

A broad database script added or populated `school_year = 2025-2026` across many tables. Some of those tables should never be school-year scoped.

Examples of tables that must not be filtered directly by school year:

- `users`
- `roles`
- `permissions`
- `role_permissions`
- `user_roles`
- `settings`
- `configuration`
- `preferences`
- `school_years`
- `login_activity`
- `ip_attempts`
- `password_resets`
- `migrations`
- `families`
- `students`
- `parents`
- `teachers`
- `staff`

CodeIgniter models and services must not decide scope based only on whether a `school_year` column exists. A bad migration can add a column. It cannot redefine the business meaning of a table, despite databases occasionally behaving as if they have opinions.

---

## Current Database Assumptions

Treat the following as the working database state before application changes:

- Valid year-owned records are tagged with `school_year = 2025-2026`.
- Optional-context tables keep `school_year` nullable and without a default.
- `notifications`, `user_notifications`, and `payment_notification_logs` were verified with no forced school-year value after cleanup.
- Some global tables may still physically contain an incorrect `school_year` column until application references are removed and a cleanup migration is executed.

Immediate application rule:

```text
YEAR_SCOPED:
    Require validated selected school-year context.

GLOBAL:
    Never add a school_year condition.

IDENTITY_WITH_YEAR_RELATION:
    Load the identity globally and filter participation through an enrollment,
    assignment, membership, or other year-owned relationship table.

CONTEXT:
    Keep school_year nullable and filter it only in explicitly year-specific views.
```

Do not run another script that stamps all nullable context rows with the active year.

---

## Target Table Classification

Create one explicit source of truth for table behavior.

### 1. Year-Owned Tables

These tables require school-year filtering for normal reads and writes.

Examples:

- `classes`
- `classSection`
- `sections`
- `enrollments`
- `student_class`
- `teacher_class`
- `attendance_data`
- `attendance_day`
- `attendance_record`
- `attendance_tracking`
- `class_progress_reports`
- `exam_drafts`
- `exams`
- `final_exam`
- `final_score`
- `semester_scores`
- `invoices`
- `invoice_installments`
- `payments`
- `payment_transactions`
- `manual_payments`
- `expenses`
- `refunds`
- `calendar_events`
- `events`
- `parent_notifications`
- `parent_meeting_schedules`
- `print_requests`
- `report_card_acknowledgements`
- `whatsapp_group_links`
- `whatsapp_group_memberships`

CodeIgniter query-builder rule:

```php
$builder->where('school_year', $context->yearName());
```

Long-term preferred rule:

```php
$builder->where('school_year_id', $context->id());
```

### 2. Global Tables

These tables must never receive automatic school-year filtering.

Examples:

- `users`
- `authorized_users`
- `roles`
- `permissions`
- `role_permissions`
- `user_roles`
- `nav_items`
- `role_nav_items`
- `parent_accounts`
- `settings`
- `configuration`
- `preferences`
- `user_preferences`
- `email_templates`
- `school_years`
- `migrations`
- `cache`
- `cache_locks`
- `sessions`
- `personal_access_tokens`
- `password_resets`
- `password_reset_requests`

Rule:

```php
// No school_year condition.
```

### 3. Global Identity Tables With Year-Owned Relationships

Identity records are global. Their participation in a school year is year-specific.

Global identity examples:

- `families`
- `parents`
- `students`
- `teachers`
- `staff`
- `family_guardians`
- `family_students`
- `emergency_contacts`
- `student_allergies`
- `student_medical_conditions`

Year-specific relationship examples:

```text
enrollments
- student_id
- school_year_id or school_year
- class_id
- status
```

```text
family_school_years
- family_id
- school_year_id or school_year
- status
- opening_balance
```

```text
teacher_class
- teacher_id
- class_id
- school_year_id or school_year
```

Correct CodeIgniter query pattern:

```php
$builder = $db->table('students s');
$builder
    ->join('enrollments e', 'e.student_id = s.id')
    ->where('e.school_year', $context->yearName())
    ->select('s.*')
    ->distinct();
```

Incorrect pattern:

```php
$db->table('students')
    ->where('students.school_year', $context->yearName());
```

Use the real relationship path defined by the schema. Do not invent a direct join because it looks pleasantly short.

### 4. Nullable Context Tables

These tables are global or operational records that may optionally include school-year context.

Examples:

- `audit_logs`
- `communication_logs`
- `notifications`
- `notification_recipients`
- `user_notifications`
- `messages`
- `support_requests`
- `contactus`
- `finance_notification_logs`
- `payment_notification_logs`

Rules:

- `school_year` remains nullable.
- Normal global views must not automatically hide rows.
- Year-specific views may include global rows plus selected-year rows.
- Strict school-year reports may include only selected-year rows.
- Creation logic must not automatically stamp the active year unless the action is genuinely year-specific.

Optional-context query:

```php
$builder
    ->groupStart()
        ->where('school_year', null)
        ->orWhere('school_year', $context->yearName())
    ->groupEnd();
```

Strict year-only query:

```php
$builder->where('school_year', $context->yearName());
```

---

## Phase 1: Add Central Table Metadata

Create:

```text
app/Support/SchoolYear/SchoolYearTableRegistry.php
```

```php
<?php

namespace App\Support\SchoolYear;

final class SchoolYearTableRegistry
{
    public const YEAR_SCOPED = [
        'classes',
        'classSection',
        'sections',
        'enrollments',
        'student_class',
        'teacher_class',
        'attendance_data',
        'attendance_day',
        'attendance_record',
        'attendance_tracking',
        'class_progress_reports',
        'exam_drafts',
        'exams',
        'final_exam',
        'final_score',
        'semester_scores',
        'invoices',
        'invoice_installments',
        'payments',
        'payment_transactions',
        'manual_payments',
        'expenses',
        'refunds',
        'calendar_events',
        'events',
        'parent_notifications',
        'parent_meeting_schedules',
        'print_requests',
        'report_card_acknowledgements',
        'whatsapp_group_links',
        'whatsapp_group_memberships',
    ];

    public const GLOBAL = [
        'users',
        'authorized_users',
        'roles',
        'permissions',
        'role_permissions',
        'user_roles',
        'nav_items',
        'role_nav_items',
        'parent_accounts',
        'settings',
        'configuration',
        'preferences',
        'user_preferences',
        'email_templates',
        'school_years',
        'migrations',
        'cache',
        'cache_locks',
        'sessions',
        'personal_access_tokens',
        'password_resets',
        'password_reset_requests',
    ];

    public const IDENTITY_WITH_YEAR_RELATION = [
        'families',
        'parents',
        'students',
        'teachers',
        'staff',
        'family_guardians',
        'family_students',
        'emergency_contacts',
        'student_allergies',
        'student_medical_conditions',
    ];

    public const CONTEXT = [
        'audit_logs',
        'communication_logs',
        'notifications',
        'notification_recipients',
        'user_notifications',
        'messages',
        'support_requests',
        'contactus',
        'finance_notification_logs',
        'payment_notification_logs',
    ];

    public static function isYearScoped(string $table): bool
    {
        return in_array($table, self::YEAR_SCOPED, true);
    }

    public static function isGlobal(string $table): bool
    {
        return in_array($table, self::GLOBAL, true);
    }

    public static function isIdentityWithYearRelation(string $table): bool
    {
        return in_array($table, self::IDENTITY_WITH_YEAR_RELATION, true);
    }

    public static function isContext(string $table): bool
    {
        return in_array($table, self::CONTEXT, true);
    }

    public static function categoryOf(string $table): string
    {
        return match (true) {
            self::isYearScoped($table) => 'YEAR_SCOPED',
            self::isGlobal($table) => 'GLOBAL',
            self::isIdentityWithYearRelation($table) => 'IDENTITY_WITH_YEAR_RELATION',
            self::isContext($table) => 'CONTEXT',
            default => throw new \InvalidArgumentException(
                "Table '{$table}' is not registered for school-year behavior."
            ),
        };
    }
}
```

Every table touched by school-year logic must be classified. An unclassified table should fail review or automated checks rather than quietly inheriting whatever condition happened to be nearby.

---

## Phase 2: Add a School-Year Context Value Object

Create:

```text
app/Support/SchoolYear/SchoolYearContext.php
```

```php
<?php

namespace App\Support\SchoolYear;

final class SchoolYearContext
{
    public function __construct(
        private readonly int $id,
        private readonly string $yearName,
        private readonly string $status,
        private readonly bool $explicitSelection = false,
    ) {
    }

    public function id(): int
    {
        return $this->id;
    }

    public function yearName(): string
    {
        return $this->yearName;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function isActive(): bool
    {
        return $this->status === 'active';
    }

    public function isReadonly(): bool
    {
        return in_array($this->status, ['closed', 'archived'], true);
    }

    public function isExplicitSelection(): bool
    {
        return $this->explicitSelection;
    }
}
```

Do not pass raw strings such as `2025-2026` across controllers and services as the main context object. Raw strings cannot carry status, ID, writability, or validation state.

---

## Phase 3: Add a SchoolYearContextService

Create or update:

```text
app/Services/SchoolYearContextService.php
```

Responsibilities:

1. Read explicit school year from route placeholders or query parameters.
2. Support `school_year_id` and `school_year` during the transition period.
3. Validate that the requested school year exists.
4. Validate that the authenticated user may access it.
5. Fall back to a session-selected year.
6. Fall back to the active year.
7. Fail clearly if no valid year can be resolved.
8. Return a `SchoolYearContext` object.
9. Never trust the frontend to determine whether a year is writable.

Resolution order:

```text
1. Route placeholder
2. Query parameter school_year_id
3. Query parameter school_year
4. Session-selected school year
5. Active school year from school_years
6. Throw a controlled exception if none exists
```

Suggested implementation outline:

```php
<?php

namespace App\Services;

use App\Models\SchoolYearModel;
use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\HTTP\IncomingRequest;
use RuntimeException;

final class SchoolYearContextService
{
    public function __construct(
        private readonly SchoolYearModel $schoolYearModel,
    ) {
    }

    public function resolve(
        IncomingRequest $request,
        ?int $routeSchoolYearId = null
    ): SchoolYearContext {
        $requestedId = $routeSchoolYearId
            ?? $this->normalizeInt($request->getGet('school_year_id'));

        $requestedName = trim((string) $request->getGet('school_year'));

        if ($requestedId !== null) {
            $row = $this->schoolYearModel->find($requestedId);

            if ($row === null) {
                throw new RuntimeException('Selected school year was not found.');
            }

            return $this->fromRow($row, true);
        }

        if ($requestedName !== '') {
            $row = $this->schoolYearModel
                ->where('name', $requestedName)
                ->first();

            if ($row === null) {
                throw new RuntimeException('Selected school year was not found.');
            }

            return $this->fromRow($row, true);
        }

        $sessionYearId = session('selected_school_year_id');

        if (is_numeric($sessionYearId)) {
            $row = $this->schoolYearModel->find((int) $sessionYearId);

            if ($row !== null) {
                return $this->fromRow($row, false);
            }
        }

        $active = $this->schoolYearModel
            ->where('status', 'active')
            ->orderBy('id', 'DESC')
            ->first();

        if ($active === null) {
            throw new RuntimeException('No active school year is configured.');
        }

        return $this->fromRow($active, false);
    }

    private function normalizeInt(mixed $value): ?int
    {
        if ($value === null || $value === '' || ! ctype_digit((string) $value)) {
            return null;
        }

        return (int) $value;
    }

    private function fromRow(array $row, bool $explicit): SchoolYearContext
    {
        return new SchoolYearContext(
            id: (int) $row['id'],
            yearName: (string) $row['name'],
            status: (string) $row['status'],
            explicitSelection: $explicit,
        );
    }
}
```

Register the service in:

```text
app/Config/Services.php
```

```php
public static function schoolYearContext(bool $getShared = true)
{
    if ($getShared) {
        return static::getSharedInstance('schoolYearContext');
    }

    return new \App\Services\SchoolYearContextService(
        model(\App\Models\SchoolYearModel::class)
    );
}
```

Use dependency injection where practical. Calling `service()` from every random method is convenient until testing begins and the convenience invoice arrives.

---

## Phase 4: Add a Write Guard

Create:

```text
app/Services/SchoolYearWriteGuard.php
```

```php
<?php

namespace App\Services;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Exceptions\PageForbiddenException;

final class SchoolYearWriteGuard
{
    public function assertWritable(
        SchoolYearContext $context,
        bool $allowDraftForAdmin = false,
        bool $isAdmin = false
    ): void {
        if ($context->status() === 'active') {
            return;
        }

        if (
            $context->status() === 'draft'
            && $allowDraftForAdmin
            && $isAdmin
        ) {
            return;
        }

        throw PageForbiddenException::forPageForbidden(
            'The selected school year is read-only.'
        );
    }
}
```

Suggested status behavior:

```text
active:
    Writable.

closing:
    Restricted to explicit closing operations.

closed:
    Read-only.

archived:
    Read-only.

draft:
    Admin-only when the operation explicitly permits draft changes.
```

Apply the guard to all writes affecting year-owned tables, including:

- invoices
- payments
- attendance
- exams
- scores
- classes and sections
- student placement
- teacher assignment
- events
- report-card acknowledgements
- WhatsApp group assignments
- carry-forward operations

Do not apply it to global user, role, permission, or configuration updates unless those operations modify year-owned data.

---

## Phase 5: Add Reusable Model Methods

CodeIgniter 4 does not provide Laravel-style Eloquent scopes or relationships. Do not copy `whereHas()`, traits returning Eloquent builders, or `Model::query()` into CI4 and hope PHP develops telepathy.

Use explicit model methods or repository/service query methods.

### Base method for year-owned models

Create:

```text
app/Models/Concerns/SchoolYearScopedModelTrait.php
```

```php
<?php

namespace App\Models\Concerns;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Model;

trait SchoolYearScopedModelTrait
{
    public function forSchoolYear(
        SchoolYearContext|string|int $schoolYear
    ): Model {
        if ($schoolYear instanceof SchoolYearContext) {
            if ($this->fieldExists('school_year_id')) {
                return $this->where(
                    $this->table . '.school_year_id',
                    $schoolYear->id()
                );
            }

            return $this->where(
                $this->table . '.school_year',
                $schoolYear->yearName()
            );
        }

        if (is_int($schoolYear)) {
            return $this->where(
                $this->table . '.school_year_id',
                $schoolYear
            );
        }

        return $this->where(
            $this->table . '.school_year',
            $schoolYear
        );
    }

    private function fieldExists(string $field): bool
    {
        return in_array($field, $this->db->getFieldNames($this->table), true);
    }
}
```

Important correction: the registry, not `fieldExists()`, decides whether a table is year-scoped. `fieldExists()` may only choose between the transitional columns `school_year_id` and `school_year` after the model has already been classified as year-owned.

Prefer a simpler explicit version per model during the transition:

```php
public function forSchoolYear(SchoolYearContext $context): self
{
    return $this->where('school_year', $context->yearName());
}
```

This is less magical and easier to audit.

### Optional-context model methods

Create methods only on applicable models:

```php
public function includeGlobalAndYear(
    SchoolYearContext $context
): self {
    return $this
        ->groupStart()
            ->where('school_year', null)
            ->orWhere('school_year', $context->yearName())
        ->groupEnd();
}

public function onlyForYear(
    SchoolYearContext $context
): self {
    return $this->where('school_year', $context->yearName());
}
```

### Do Not Add School-Year Methods To Global Models

Do not add automatic school-year behavior to:

- `UserModel`
- `RoleModel`
- `PermissionModel`
- `SettingModel`
- `ConfigurationModel`
- `SchoolYearModel`
- `StudentModel`
- `FamilyModel`
- `ParentModel`
- `TeacherModel`
- `StaffModel`

Identity models may expose methods that join year-owned relationship tables, but the identity table itself remains global.

---

## Phase 6: Add Repositories or Domain Services for Complex Queries

Use dedicated services or repositories for cross-table queries instead of placing joins in controllers.

Suggested files:

```text
app/Repositories/StudentRepository.php
app/Repositories/FamilyRepository.php
app/Repositories/TeacherRepository.php
app/Repositories/FinanceRepository.php
app/Services/Finance/BalanceService.php
app/Services/SchoolYearClosingService.php
```

Example student roster query:

```php
<?php

namespace App\Repositories;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Database\BaseConnection;

final class StudentRepository
{
    public function __construct(
        private readonly BaseConnection $db,
    ) {
    }

    public function findRosterForYear(
        SchoolYearContext $context,
        ?int $classId = null
    ): array {
        $builder = $this->db->table('students s');

        $builder
            ->select('s.*, e.class_id, e.status AS enrollment_status')
            ->join('enrollments e', 'e.student_id = s.id')
            ->where('e.school_year', $context->yearName());

        if ($classId !== null) {
            $builder->where('e.class_id', $classId);
        }

        return $builder
            ->orderBy('s.last_name', 'ASC')
            ->orderBy('s.first_name', 'ASC')
            ->get()
            ->getResultArray();
    }
}
```

Example global profile with year-owned child data:

```php
$family = $familyModel->find($familyId);

if ($family === null) {
    throw PageNotFoundException::forPageNotFound('Family not found.');
}

$invoices = $invoiceModel
    ->where('family_id', $familyId)
    ->forSchoolYear($context)
    ->findAll();
```

---

## Phase 7: Resolve Context in Controllers Consistently

Create a base API controller helper or a small controller trait.

Suggested file:

```text
app/Controllers/BaseApiController.php
```

```php
<?php

namespace App\Controllers;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\RESTful\ResourceController;
use Throwable;

abstract class BaseApiController extends ResourceController
{
    protected function resolveSchoolYearContext(
        ?int $routeSchoolYearId = null
    ): SchoolYearContext {
        try {
            return service('schoolYearContext')->resolve(
                $this->request,
                $routeSchoolYearId
            );
        } catch (Throwable $e) {
            log_message('warning', 'School-year resolution failed: {message}', [
                'message' => $e->getMessage(),
            ]);

            throw $e;
        }
    }
}
```

Controller usage:

```php
public function index()
{
    $context = $this->resolveSchoolYearContext();

    $rows = $this->invoiceModel
        ->forSchoolYear($context)
        ->orderBy('id', 'DESC')
        ->findAll();

    return $this->respond([
        'school_year' => [
            'id' => $context->id(),
            'name' => $context->yearName(),
            'status' => $context->status(),
            'readonly' => $context->isReadonly(),
        ],
        'data' => $rows,
    ]);
}
```

For year-owned endpoints, never silently continue without a valid context.

---

## Phase 8: Update Routes

Use explicit school-year routes for year-owned resources.

In:

```text
app/Config/Routes.php
```

Recommended structure:

```php
$routes->group('api/v1', ['filter' => 'auth'], static function ($routes) {
    // Global resources
    $routes->get('users', 'Api\UserController::index');
    $routes->get('roles', 'Api\RoleController::index');
    $routes->get('permissions', 'Api\PermissionController::index');
    $routes->get('settings', 'Api\SettingController::index');
    $routes->get('school-years', 'Api\SchoolYearController::index');

    // Current/resolved school year
    $routes->group('school-years/current', static function ($routes) {
        $routes->get('classes', 'Api\ClassController::index');
        $routes->get('families', 'Api\FamilyYearController::index');
        $routes->get(
            'reports/closing',
            'Api\SchoolYearClosingController::show'
        );
    });

    // Explicit selected school year
    $routes->group(
        'school-years/(:num)',
        static function ($routes) {
            $routes->get('classes', 'Api\ClassController::index/$1');
            $routes->get(
                'families',
                'Api\FamilyYearController::index/$1'
            );
            $routes->get(
                'reports/closing',
                'Api\SchoolYearClosingController::show/$1'
            );
        }
    );

    // Global profile with year-specific child endpoints
    $routes->get('families/(:num)', 'Api\FamilyController::show/$1');
    $routes->get(
        'school-years/(:num)/families/(:num)/invoices',
        'Api\FamilyInvoiceController::index/$1/$2'
    );
});
```

Do not put global resources under school-year routes merely because the frontend currently appends `school_year` to every URL.

Bad:

```text
/api/v1/school-years/4/users
```

Correct:

```text
/api/v1/users
/api/v1/school-years/4/teacher-assignments
```

Maintain compatibility with current query parameters temporarily, but route all resolution through `SchoolYearContextService`.

---

## Phase 9: Add Request Filters Only for Cross-Cutting Validation

A CodeIgniter filter may validate that a route requiring school-year context has one, but it must not mutate every database query globally.

Suggested filter:

```text
app/Filters/RequireSchoolYearFilter.php
```

Responsibilities:

- Resolve and validate school-year context.
- Store the resolved context as a request attribute substitute, service state, or controller-accessible object.
- Return a controlled `400`, `403`, or `404` response when invalid.
- Never add SQL conditions itself.
- Never run on global endpoints.

Register an alias in:

```text
app/Config/Filters.php
```

```php
public array $aliases = [
    // ...
    'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
];
```

Apply it only to year-owned route groups.

Do not implement a universal model callback that appends `school_year` to every query. That would recreate the original bug with more ceremony.

---

## Phase 10: Audit and Replace Existing Queries

Search the CodeIgniter project:

```bash
rg -n "school_year" app tests database
rg -n "school_year_id" app tests database
rg -n "getGet\(['\"]school_year" app
rg -n "getVar\(['\"]school_year" app
rg -n "where\(['\"][^'\"]*school_year" app
rg -n "db->table|builder\(" app
rg -n "allowedFields" app/Models
```

Classify every use:

1. **Year-owned table**  
   Keep the filter, but obtain the value from validated context.

2. **Global table**  
   Remove the filter and remove `school_year` from model `allowedFields`.

3. **Identity table**  
   Replace direct filtering with joins through enrollment or assignment tables.

4. **Optional-context table**  
   Choose global, global-plus-year, or strict-year behavior based on the endpoint.

5. **Raw SQL**  
   Add explicit table aliases and scope only the year-owned table.

6. **Write path**  
   Add the write guard and force the year value from backend context.

Bad:

```php
$currentYear = $this->request->getGet('school_year');

$users = $this->userModel
    ->where('school_year', $currentYear)
    ->findAll();
```

Correct:

```php
$users = $this->userModel
    ->orderBy('last_name', 'ASC')
    ->orderBy('first_name', 'ASC')
    ->findAll();
```

Bad:

```php
$students = $this->studentModel
    ->where('school_year', $currentYear)
    ->findAll();
```

Correct:

```php
$context = $this->resolveSchoolYearContext();

$students = $this->studentRepository
    ->findRosterForYear($context);
```

Bad write:

```php
$data = $this->request->getJSON(true);
$this->paymentModel->insert($data);
```

Correct write:

```php
$context = $this->resolveSchoolYearContext();
service('schoolYearWriteGuard')->assertWritable($context);

$data = $this->request->getJSON(true) ?? [];
$data['school_year'] = $context->yearName();
$data['school_year_id'] = $context->id();

unset($data['requested_school_year'], $data['is_readonly']);

$this->paymentModel->insert($data);
```

The client must not be allowed to write into one year while displaying another.

---

## Phase 11: Update Model Definitions

Review every CodeIgniter model.

For year-owned models:

- Include `school_year` and, during transition, `school_year_id` in `$allowedFields`.
- Add validation rules where required.
- Add `forSchoolYear()` or equivalent explicit methods.
- Do not automatically default to the active year inside `beforeInsert` unless every creation path is guaranteed to be year-specific.
- Prefer services/controllers to set the validated year explicitly.

Example:

```php
<?php

namespace App\Models;

use App\Models\Concerns\SchoolYearScopedModelTrait;
use CodeIgniter\Model;

class InvoiceModel extends Model
{
    use SchoolYearScopedModelTrait;

    protected $table = 'invoices';
    protected $primaryKey = 'id';
    protected $returnType = 'array';

    protected $allowedFields = [
        'family_id',
        'student_id',
        'amount',
        'balance',
        'status',
        'school_year',
        'school_year_id',
        'created_by',
        'updated_by',
    ];

    protected $validationRules = [
        'family_id' => 'required|is_natural_no_zero',
        'amount' => 'required|decimal',
        'school_year' => 'required|max_length[20]',
    ];
}
```

For global models:

- Remove `school_year` and `school_year_id` from `$allowedFields`.
- Remove school-year validation rules.
- Remove `beforeFind`, `beforeInsert`, or `beforeUpdate` callbacks that add the active year.
- Remove constructor logic reading `configuration.semester` or `configuration.school_year` unless the model genuinely owns year-specific records.

For optional-context models:

- Keep `school_year` nullable.
- Do not mark it `required`.
- Populate it only when the action has explicit school-year meaning.

---

## Phase 12: Update Finance First

Finance is the highest-risk area.

Audit:

- family and parent balances
- invoice listings
- invoice installment calculations
- payment listings
- payment allocation
- manual payment creation
- reimbursements
- refunds
- discounts and discount usage
- event charges
- carry-forward calculations
- closing previews
- closing reports
- opening balances
- transaction and notification logs

Rules:

1. Current-year balances use only current-year financial records.
2. Historical debt does not appear as current-year overdue debt unless an explicit carry-forward transaction exists.
3. Carry-forward creates a target-year record. It must not mutate historical invoices.
4. Closing previews use the selected year, not whatever year is globally active.
5. Every source table in an aggregate query must be reviewed separately.
6. Payment transactions and invoice-event links must inherit or explicitly store the same school year as their parent record.
7. Optional logs may remain null and must not be treated as financial ownership records.

Example balance service:

```php
public function calculateFamilyBalance(
    int $familyId,
    SchoolYearContext $context
): array {
    $invoiceTotal = (float) $this->db
        ->table('invoices')
        ->selectSum('total_amount', 'total')
        ->where('family_id', $familyId)
        ->where('school_year', $context->yearName())
        ->get()
        ->getRow('total');

    $paymentTotal = (float) $this->db
        ->table('payments')
        ->selectSum('paid_amount', 'total')
        ->where('family_id', $familyId)
        ->where('school_year', $context->yearName())
        ->get()
        ->getRow('total');

    return [
        'invoice_total' => $invoiceTotal,
        'payment_total' => $paymentTotal,
        'balance' => $invoiceTotal - $paymentTotal,
    ];
}
```

Do not compute a balance by summing all historical invoices and payments for a family. That is not an accounting shortcut. It is data contamination.

Use database transactions for closing and carry-forward operations:

```php
$this->db->transException(true)->transStart();

try {
    // Lock/check source year.
    // Create target-year carry-forward records.
    // Mark closing batch.
    // Write audit records.
    $this->db->transComplete();
} catch (\Throwable $e) {
    log_message('error', 'School-year closing failed: {message}', [
        'message' => $e->getMessage(),
    ]);

    throw $e;
}
```

---

## Phase 13: Update Academic Queries

Audit:

- classes
- sections
- class-section mappings
- enrollments
- student placement
- teacher assignments
- attendance
- progress reports
- exam drafts
- exams
- semester scores
- final scores
- promotion
- report cards

Rules:

- `students` and `teachers` remain global.
- `classes`, `sections`, assignments, and enrollments are year-owned.
- Active-year rosters come from enrollment tables.
- Teacher pages derive students from year-owned assignments and enrollments.
- Reports always use the selected school year.
- Semester filtering is applied only to semester-owned academic records, not global identity or annual records.

Example:

```php
$builder = $this->db->table('classes c');

$classes = $builder
    ->select('c.*, COUNT(DISTINCT e.student_id) AS student_count')
    ->join(
        'enrollments e',
        'e.class_id = c.id AND e.school_year = c.school_year',
        'left'
    )
    ->where('c.school_year', $context->yearName())
    ->groupBy('c.id')
    ->orderBy('c.name', 'ASC')
    ->get()
    ->getResultArray();
```

When both tables are year-owned, join on identifiers and ensure their school-year values agree.

---

## Phase 14: Update Identity and Profile Pages

Audit:

- parent profiles
- family management
- student profiles
- teacher profiles
- staff pages

Rules:

1. Profile identity loads by global primary key.
2. Year-specific tabs use the selected context.
3. Historical profile activity remains accessible.
4. A person is not hidden globally merely because they lack an enrollment for the selected year.
5. Roster pages may show only identities participating in the selected year.
6. Profile pages and roster pages are different use cases. Do not force them through one misleading query.

Example:

```php
$parent = $this->parentModel->find($parentId);

if ($parent === null) {
    return $this->failNotFound('Parent not found.');
}

$invoices = $this->invoiceModel
    ->where('parent_id', $parentId)
    ->forSchoolYear($context)
    ->findAll();

return $this->respond([
    'parent' => $parent,
    'school_year' => $context->yearName(),
    'invoices' => $invoices,
]);
```

---

## Phase 15: Remove School-Year Logic From Global Features

Remove year filtering from:

- login
- token validation
- password reset
- sessions
- user management
- role and permission checks
- navigation/menu construction
- settings
- configuration
- global email templates
- cache
- migrations
- system health endpoints

Bad:

```php
$this->permissionModel
    ->where('school_year', $context->yearName())
    ->findAll();
```

Correct:

```php
$this->permissionModel
    ->orderBy('name', 'ASC')
    ->findAll();
```

If a setting truly varies by year, create a separate table:

```text
school_year_settings
- id
- school_year_id
- config_key
- config_value
```

Do not turn `configuration` into a table where some rows are global and some are secretly annual without an explicit design.

---

## Phase 16: Update Frontend Integration

The backend remains authoritative, but the frontend should use a centralized school-year state.

Required frontend state:

```text
activeSchoolYear
selectedSchoolYear
selectedSchoolYearId
selectedSchoolYearName
selectedSchoolYearStatus
isReadonly
```

Frontend rules:

1. Year-owned pages send the selected school-year ID.
2. Global pages do not depend on school-year parameters.
3. Navigation preserves the selected year only where useful.
4. Mutation buttons are disabled for read-only years.
5. The API still enforces writability even when buttons are disabled.
6. Dropdown lists use alphabetical ordering where applicable.
7. Tables support sorting consistently.
8. A selected year in the URL must match the resolved year returned by the API.

Recommended API usage:

```ts
api.get(`/api/v1/school-years/${selectedSchoolYearId}/classes`);
api.get(`/api/v1/school-years/${selectedSchoolYearId}/reports/closing`);

api.get('/api/v1/users');
api.get('/api/v1/roles');
api.get('/api/v1/settings');

api.get(`/api/v1/families/${familyId}`);
api.get(
  `/api/v1/school-years/${selectedSchoolYearId}/families/${familyId}/invoices`
);
```

During migration, existing endpoints using:

```text
?school_year_id=4&school_year=2025-2026
```

may remain, but the backend must reject conflicting values rather than choosing whichever one happens to be read first.

---

## Phase 17: Add CodeIgniter Tests

Use CodeIgniter 4 testing utilities and PHPUnit.

Suggested test locations:

```text
tests/unit/Support/SchoolYear/
tests/unit/Services/
tests/database/Models/
tests/feature/Api/
```

Run:

```bash
php spark test
```

or:

```bash
vendor/bin/phpunit
```

### Unit Tests

Test:

- `SchoolYearTableRegistry::categoryOf()`
- context resolution precedence
- invalid ID handling
- invalid year-name handling
- active-year fallback
- read-only status handling
- write-guard behavior

### Database Tests

Use:

```php
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
```

Test year-owned models:

- classes return only selected-year rows
- attendance returns only selected-year rows
- invoices return only selected-year rows
- payments return only selected-year rows
- progress reports return only selected-year rows

Test global models:

- users load regardless of selected year
- roles load regardless of selected year
- permissions load regardless of selected year
- configuration loads regardless of selected year
- all school years remain visible

Test identity behavior:

- student profile loads globally
- student roster changes by selected year
- family profile loads globally
- family balance changes by selected year
- teacher profile loads globally
- teacher assignments change by selected year

Test context tables:

- null-context logs remain visible in global views
- selected-year views include the intended rows
- strict reports exclude unrelated and null-context rows
- notification rows are not automatically stamped

### Feature/API Tests

Use `FeatureTestTrait`.

Verify:

- missing required context returns an error
- invalid school-year ID returns `404` or validation failure
- unauthorized historical access returns `403`
- closed-year writes return `403`
- active-year writes succeed
- global endpoints work without school-year parameters
- conflicting `school_year_id` and `school_year` values are rejected
- endpoint response includes resolved school-year metadata

### Finance Regression Tests

At minimum:

- old-year invoice does not affect current-year balance
- old-year payment does not affect current-year balance
- carry-forward creates exactly one target-year record
- rerunning carry-forward is idempotent or blocked
- closing preview and closing execution produce matching totals
- rollback occurs when any closing step fails

---

## Phase 18: Add Static and Runtime Safety Checks

Add a CI script that searches for suspicious global-table filters.

Suggested script:

```text
scripts/check-school-year-filters.php
```

It should flag patterns such as:

```text
UserModel ... where('school_year'
RoleModel ... where('school_year'
PermissionModel ... where('school_year'
ConfigurationModel ... where('school_year'
students.school_year
families.school_year
parents.school_year
teachers.school_year
staff.school_year
```

Also verify:

- all year-owned models contain explicit year filtering methods
- global models do not contain school-year callbacks
- every year-owned insert path sets a backend-resolved school year
- every year-owned mutation invokes the write guard

This check will not prove correctness, but it catches predictable regressions before they become production archaeology.

---

## Phase 19: Database Cleanup Migration

After application code no longer references incorrect columns, create a CodeIgniter migration.

Generate it:

```bash
php spark make:migration RemoveInvalidSchoolYearColumns
```

Suggested migration principles:

1. Check the table and field before altering it.
2. Drop indexes or foreign keys first where necessary.
3. Remove only columns confirmed to be invalid.
4. Do not remove nullable context columns.
5. Keep the `down()` method conservative. Recreating a column does not restore deleted values.

Candidate invalid columns:

```text
school_years.school_year
users.school_year
authorized_users.school_year
roles.school_year
permissions.school_year
role_permissions.school_year
user_roles.school_year
nav_items.school_year
role_nav_items.school_year
settings.school_year
configuration.school_year
preferences.school_year
user_preferences.school_year
email_templates.school_year
attendance_comment_template.school_year
admin_notification_subjects.school_year
login_activity.school_year
ip_attempts.school_year
password_resets.school_year
password_reset_requests.school_year
migrations.school_year
families.school_year
parents.school_year
students.school_year
teachers.school_year
staff.school_year
family_guardians.school_year
family_students.school_year
emergency_contacts.school_year
student_allergies.school_year
student_medical_conditions.school_year
inventory_categories.school_year
inventory_items.school_year
suppliers.school_year
supplies.school_year
supply_categories.school_year
questiontypes.school_year
```

Migration outline:

```php
<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class RemoveInvalidSchoolYearColumns extends Migration
{
    private array $targets = [
        'users',
        'roles',
        'permissions',
        'configuration',
        'families',
        'students',
        'parents',
        'teachers',
        'staff',
    ];

    public function up()
    {
        foreach ($this->targets as $table) {
            if (
                $this->db->tableExists($table)
                && $this->db->fieldExists('school_year', $table)
            ) {
                $this->forge->dropColumn($table, 'school_year');
            }
        }
    }

    public function down()
    {
        foreach ($this->targets as $table) {
            if (
                $this->db->tableExists($table)
                && ! $this->db->fieldExists('school_year', $table)
            ) {
                $this->forge->addColumn($table, [
                    'school_year' => [
                        'type' => 'VARCHAR',
                        'constraint' => 20,
                        'null' => true,
                    ],
                ]);
            }
        }
    }
}
```

Before running:

```bash
php spark migrate:status
mysqldump --no-data school_view > school_view_schema_before_cleanup.sql
mysqldump school_view > school_view_backup_before_cleanup.sql
php spark migrate
php spark test
```

Cleanup order:

1. Audit code references.
2. Remove invalid model fields and filters.
3. Deploy application changes.
4. Back up the database.
5. Run migration.
6. Run automated tests.
7. Run API smoke tests.
8. Review application logs.

Dropping columns before removing application references is merely debugging with extra property damage.

---

## Phase 20: Migrate From Text to `school_year_id`

The final design should use foreign keys.

Migration sequence:

1. Add nullable `school_year_id` to year-owned tables.
2. Backfill it from `school_year`.
3. Add indexes.
4. Update models to allow both fields temporarily.
5. Update writes to set both fields.
6. Change reads to use `school_year_id`.
7. Validate that every year-owned record has a valid ID.
8. Add foreign keys.
9. Make `school_year_id` non-null where ownership is mandatory.
10. Remove text `school_year` only after all application and reporting code uses IDs.

Generate migrations:

```bash
php spark make:migration AddSchoolYearIdToYearOwnedTables
php spark make:migration BackfillSchoolYearIds
php spark make:migration EnforceSchoolYearForeignKeys
```

Backfill example:

```php
$this->db->query(
    'UPDATE invoices i
     JOIN school_years sy ON sy.name = i.school_year
       SET i.school_year_id = sy.id
     WHERE i.school_year_id IS NULL'
);
```

Index example:

```php
$this->forge->addKey('school_year_id');
```

Foreign key example:

```php
$this->forge->addForeignKey(
    'school_year_id',
    'school_years',
    'id',
    'RESTRICT',
    'CASCADE'
);
```

Use `RESTRICT` for deletion unless there is a deliberate archival strategy. Cascading deletion of a school year through finance and academic history would be an impressively efficient catastrophe.

---

## Implementation Order

1. Confirm the table classification against the actual schema.
2. Add `SchoolYearTableRegistry`.
3. Add `SchoolYearContext`.
4. Add `SchoolYearContextService`.
5. Register the service in `Config\Services`.
6. Add `SchoolYearWriteGuard`.
7. Add explicit year-scoping methods to year-owned models.
8. Remove school-year behavior from global models.
9. Add repositories/services for identity relationship queries.
10. Update finance queries.
11. Update school-year closing preview and execution.
12. Update academic and attendance queries.
13. Update parent, family, student, teacher, and staff pages.
14. Update API routes and controller context resolution.
15. Update frontend school-year state and endpoint usage.
16. Add unit, database, feature, and regression tests.
17. Add static checks for prohibited filters.
18. Deploy code changes.
19. Back up the database.
20. Remove invalid columns through a CI4 migration.
21. Begin the `school_year_id` foreign-key migration.

---

## Acceptance Criteria

The implementation is complete only when:

- No global table is filtered by `school_year`.
- No identity table is directly filtered by `school_year`.
- Every year-owned read uses validated selected-year context.
- Every year-owned write forces the year from backend context.
- Every year-owned write validates that the selected year is writable.
- Finance totals never mix years unless using an explicit carry-forward record.
- Closed and archived years reject normal mutations.
- Context tables remain nullable and are not automatically stamped.
- Global authentication and configuration work regardless of selected year.
- Profile identity remains accessible across years.
- Rosters and assignments change correctly with selected year.
- APIs return resolved year metadata.
- Conflicting year parameters are rejected.
- Tests prove that historical records do not leak into active-year pages.
- Tests prove that global pages do not break when the selected year changes.
- No required year-owned endpoint silently returns all rows when context is absent.
- All dropdown lists are alphabetically ordered where business ordering does not override it.
- All data tables provide consistent sorting.

---

## Non-Negotiable Rule

Never decide whether to apply school-year filtering merely by checking whether a column exists.

Column existence describes the current schema.

The table registry and domain rules define the intended behavior.
