# Zighis (A.M.E Zion Girls SHS, Winneba) — Admissions Paywall & Results Pipeline

## Status (updated as work lands)

- [x] **§4 Admissions paywall — DONE.** Migration, `AdmissionNumberCounter` model, `AdmissionNumberGenerator` service (race-tested with 20 truly concurrent processes — see note below), `Application` model fields, both payment paths (Paystack + manual MoMo) via new `ApplicationPaymentController` (extends `PublicWebsiteController` to reuse its settings/theme helpers rather than duplicating them), admin "Verify Payment" action on `ApplicationResource`, pre-filled PDF via `downloadFilledApplicationForm`, popup notice on the success page, new `admission_programs`/`admission_fee_amount`/`admission_number_prefix`/`admission_momo_number`/`admission_momo_name` settings surfaced in `SchoolSettings.php`. Routes registered in `routes/tenant.php`. Verified: app boots (417 routes), migration applies cleanly on the `demo` tenant, all touched Blade views compile via `artisan view:cache`.
  - **Important fix during testing**: the naive lock-based counter increment failed under *real* concurrency — MariaDB threw `SQLSTATE[HY000]: 1020 "Record has changed since last read"` on `SELECT ... FOR UPDATE` when hammered with 8 parallel requests (no duplicate numbers were ever issued, but ~25% of requests errored out). Laravel's built-in transaction deadlock-retry doesn't recognize this specific error. Fixed by wrapping the whole transaction in an explicit retry loop (5 attempts, small increasing backoff) in `AdmissionNumberGenerator`. Re-tested with 20 truly parallel processes: all 20 got unique sequential numbers, zero errors. **If you touch this file, keep the retry wrapper** — it's not optional under a real admissions rush.
  - Not yet done: nothing configured for *this* client's actual settings values yet (Zighis prefix, GHS 50 fee, program list, momo number) — those need to be entered through the new Admission Fee & Payment section of School Settings once this ships, they're not seeded/hardcoded anywhere.
- [x] **§5.1 Results 9-component model — DONE.** Migration adds `ica1/ica2/icp1/icp2/gp1/gp2/practical/mid_term/end_term/assessor` to `results` (nullable, additive — schools not using this are unaffected). `config('temseeedu.assessment_components')` holds max-points/weight per category. `Result::computeTotal()` now branches: uses the new components when any are set, else falls back to the original class_score/exam_score path.
  - **Correctness note, read before touching this**: the formula is *not* a simple weighted average. It replicates the school's actual spreadsheet cell-by-cell (verified against the real file's Excel formulas, not just its displayed values — the displayed "100% OF TOTAL CLASS SCORE" column is a *decoy*, it's an intermediate value, not the final grade): sum the 8 non-exam components as raw points out of a combined max of 500 → percentage → **halve and round up, capped at 50** → separately, halve+round-up+cap end_term the same way → sum the two halves for the final 0-100 total. A naive continuous weighted-average gives a *very close* but not identical number, and can flip a result across a grade-band boundary (verified: one real row lands exactly on the 50-point C6/D7 boundary). Test fixtures for this are in the session's scratchpad, not committed — if you refactor this method, re-verify against real spreadsheet rows before trusting it, the two known-good pairs are: `{ica1:40,ica2:40,icp1:39,icp2:41,gp1:44,gp2:46,practical:78,mid_term:86,end_term:0}` → **42**, and `{50,50,50,50,50,50,98,100,0}` → **50**.
- [x] **§5.1 Excel importer — DONE.** `Modules\Results\Services\AssessmentSheetImporter` (`app-modules/Results/app/Services/`), wired to a new "Import Assessment Sheet" header action on `ResultResource`'s list page (admin picks Term + Academic Year + uploads the workbook). Parses every worksheet (subject/form read from the `SUBJECT:`/`FORM:` header cells, not the sheet tab name — tab names are truncated/inconsistent in the real file), resolves class by requiring `form N` or `shs N` as a whole word in the class name (a looser digit-only match was tried first and produced false positives against unrelated classes like "Primary 1" — don't reintroduce that), auto-creates missing Subjects, matches students by Surname+First Name tokens (order-independent) within the resolved class, and skips + reports anything ambiguous rather than guessing. **Verified end-to-end**: ran against the real `assessments_all_20260812.xlsx` with two fixture students seeded to match real rows — produced `total_score = 42` and `50`, matching the hand-verified values above exactly, across 28 real "Form 2" sheets. Test fixtures were cleaned up afterward; nothing from this test run is in the tenant DB.
- [x] **§5.2 eduassess push — DONE.** `App\Services\EduassessSyncService` (fans one Result row's 9 components out into up to 9 eduassess API records, matching `EXTERNAL_RESULTS_API_INTEGRATION.md`'s `/api/v1/assessments/bulk` contract exactly, chunked at 200/request). New `students.eduassess_student_number` column bridges the two systems' IDs (eduassess's own ID is unrelated to TemseeEdu's `student_id`) — populated automatically by the Excel importer (the workbook's "Student No." column *is* this ID), or settable manually. New `eduassess_base_url`/`eduassess_api_key` fields in School Settings → Academic tab. "Sync to eduassess" header action on Score Entry only appears once both are configured. **Not yet tested against a real eduassess instance** — no live URL/API key exists yet (see §6, still an open question). The HTTP call path itself (chunking, error collection, unmapped-student reporting) is implemented per spec but unverified against the real API; test with a real key before relying on it.
- [x] **§5.3 Bulk class-subject tool — DONE.** Added `Classes::subjects()` (belongsToMany, was missing — `Subject::classes()` existed but nothing pointed back). New `SubjectsRelationManager` on `ClassesResource` gives "bulk load" (Filament's `AttachAction::make()->multiple()` — select several subjects at once) and "add/drop" (per-row `DetachAction` + bulk `DetachBulkAction`) against the existing `class_subject` pivot, which previously had zero admin UI. Note: this manages *which subjects a class offers*, not per-student elective overrides (`student_subject_enrollments`, a separate existing table) — if the client actually meant per-student add/drop, that table already exists and just needs its own UI, not built yet.
- [x] **§5.3 Bulk student import — DONE.** `App\Services\StudentBulkImporter` + "Bulk Import" header action on `StudentResource`'s list page. Simple format: header row (Name*, Gender*, Class*, Date of Birth, House, Email, Status), one student per row after. Reuses the exact same `student_id` generation convention as the existing manual-create form (`TMS-YYYY-#####`) for consistency. Sets a random password (existing manual-create form has no password field on create at all despite the DB column being `NOT NULL` — pre-existing gap, not something this importer should paper over further, so it just sets a safe random one). Complements, doesn't replace, the existing manual one-by-one registration form. **Verified**: imported 2 real rows, correctly skipped a row referencing a nonexistent class with a clear reason, silently skipped a blank row.

## All Sunday-critical and fast-follow scope from the client conversation is now implemented and individually verified (migrations applied + rolled back cleanly, real spreadsheet data hand-verified, concurrency-tested). What's genuinely left:

1. **Enter this client's actual settings** — Zighis prefix, GHS 50 fee, the 5 program codes, the MoMo number/name — through School Settings → Admission Fee & Payment. Nothing is pre-filled with their real values; everything defaults to blank/off.
2. **Generate a real RSA keypair and configure it** on your own central (cloud edition) server for `LicenseServerController` to work at all (unrelated to this feature — carried over from earlier in this session, see §7).
3. **eduassess push is untested against a real instance** — no live URL/API key exists yet. Get one, then do one real test sync before relying on it.
4. **A manual smoke test of the full admissions flow** in a browser hasn't been done (everything below the HTTP layer has been verified via direct PHP execution against the real tenant DB, but no actual page has been clicked through yet) — worth doing before Sunday, particularly the Paystack redirect round-trip and the Alpine.js modal/payment-choice UI.
5. Confirm with the client: admission-number granularity (Learning Area only, as built), and whether "add/drop courses" meant the class-level tool built here or a per-student elective one.

---

Working implementation plan for a real client engagement. Written so any dev can pick this up cold if the current session runs out of context. Client wants the admissions piece **live by Sunday** (this plan was started Friday night). Everything else is fast-follow.

## 1. Client & business context

- Client: **A.M.E Zion Girls Senior High School, Winneba**. Deploying as **School Edition** (self-hosted, single-tenant).
- Admission number prefix: **"Zighis"** (the school's own short name/brand — must be configurable, not hardcoded, since we intend to resell School Edition to other schools with different requirements).
- MoMo contact for manual payments: **0245967353 — Hanson Ent. Solomon Amonoo**.
- Admission fee: **GHS 50** (mentioned in chat as "50 cedis"). Covers admission letter, prospectus, and rules & regulations — all handed out **physically at school**, not shipped/emailed. A notice to that effect must be shown after successful application.
- Every admitted student must attend in person **with a parent/guardian** for the admission process — same notice.
- Client uses (or is transitioning from/alongside) a separate existing system, **eduassess** (`https://github.com/Linxford/eduassess`), a Flask/Python results-tracking app, for continuous-assessment results. They gave us its `EXTERNAL_RESULTS_API_INTEGRATION.md` spec and a real results export (`assessments_all_20260812.xlsx`) as the shape we should support.
- Subject-combination structure comes from `SUBJECT COMBINATION (1).pdf` (shared in chat): 5 broad **Learning Areas** — Science, Visual & Performing Arts, Home Economics, General Arts, Business Studies — each with lettered **Options** (e.g. Science A/B, Home Economics A–F). The client's own admission-number examples only distinguish by **Learning Area**, not by specific Option:
  - Science, 1st applicant → `Zighis/SCI26/001`
  - Home Economics, 1st applicant → `Zighis/HE26/001`
  - **Open question**: confirm this Learning-Area-only granularity is correct (see §6).

## 2. What already exists (do not rebuild)

TemseeEdu already has a working public admissions pipeline — this work is an *extension*, not a greenfield build:

- **Public routes** (`routes/tenant.php`): `/apply` (GET form), `/apply` (POST, throttled `admission-apply`), `/apply/success/{ref}`, `/apply/track`, plus `/admissions/*` aliases.
- **Controller**: `app/Http/Controllers/PublicWebsiteController.php` — `apply()`, `storeApplication()`, `applySuccess()`, `track()`, `downloadApplicationForm()` (blank PDF via `barryvdh/laravel-dompdf`).
- **Model**: `app/Models/Application.php` — fillable includes `reference_no`, `student_name`, `class_id` (grade level, e.g. "SHS 1" — **not** subject-combination/program, see §3), guardian info, `status`, `documents` (JSON, file uploads), `custom_fields` (JSON — a school-configurable extra-fields system, see below), `entrance_exam_at/venue`. Has an `updated` model event that emails the guardian on status change.
- **Admin side**: `app/Filament/Resources/ApplicationResource.php` (+ pages), `RecentApplicationsWidget`, `AdmissionsPipeline` Filament page, `AdmissionLetterController` (generates the accepted-student admission letter PDF).
- **Settings pattern to follow**: `school_settings` is a plain key/value table. Helper methods already exist on `PublicWebsiteController`: `getSetting($key, $default)`, `getJsonSetting($key, $default)`. The admin-editable settings UI is `app/Filament/Pages/SchoolSettings.php` — it already has an `admission_custom_fields` repeater (label/key/type/required/options) that lets a school add extra application-form questions **without a schema change**. New settings we add (fee amount, momo details, program list, number prefix) should follow this exact pattern and get their own fields in that same Filament page, under the existing "Admissions" tab (~line 199 onward).
- **Payment pattern to follow**: `app-modules/Payments/app/Http/Controllers/PaystackController.php` — `checkout(Invoice $invoice)` / `callback(Request $request)`. Uses `config('services.paystack.secret_key')` (a single global key — correct for School Edition since it's one school, one Paystack account; would **not** be correct for Cloud multi-tenant without per-tenant keys, but that's out of scope here). Initializes a Paystack hosted-checkout transaction (GHS, Ghana → Paystack's hosted page auto-offers Mobile Money as a channel), verifies via `transaction/verify/{reference}` on callback. **We are cloning this pattern for Applications, not modifying it** (it stays tied to `Invoice`/fee billing for enrolled students).

## 3. Decisions already made (with the client, this session)

1. **Both payment methods, applicant's choice**: (a) automated Paystack Mobile Money checkout, and (b) manual — pay to 0245967353 (Hanson Ent.), then applicant types in their own MoMo transaction reference, held as `pending_verification` until a staff member manually confirms it in the admin panel.
2. **Untrack `dist/school-edition`, harden the license system, patch 24 dependency CVEs, fix several N+1 queries** — all completed earlier this session, unrelated to this feature but relevant repo state (see §7).
3. Admission number format: `{prefix}/{PROGRAM_CODE}{YY}/{seq}` e.g. `Zighis/SCI26/001` — must be **race-safe** (concurrent submissions must never collide) and **per-school configurable** (prefix + program code list), since we're reselling School Edition.
4. Reusability principle for *everything* in this doc: **nothing client-specific gets hardcoded**. Prefix, fee amount, MoMo number/name, program code list — all go in `school_settings`, editable per install via the existing Filament settings pattern.

## 4. Sunday-critical scope (admissions paywall)

### 4.1 Data model changes

New migration on the **tenant** connection (`database/migrations/tenant/`, matching the existing `classes` migration style):

**Extend `applications` table** — add:
- `program_code` (string, nullable) — e.g. `SCI`, `HE`, `GA`, `VPA`, `BUS`
- `admission_number` (string, nullable, unique) — only set once payment is confirmed
- `payment_status` (string, default `unpaid`) — `unpaid` | `pending_verification` | `paid`
- `payment_method` (string, nullable) — `paystack` | `manual_momo`
- `payment_reference` (string, nullable) — Paystack tx reference, or the applicant-entered MoMo transaction ID
- `payment_amount` (decimal, nullable)
- `paid_at` (timestamp, nullable)
- `payment_verified_by` (nullable FK → `users.id`, for the manual path)

**New table `admission_number_counters`**:
- `program_code` (string), `year` (string, 2-digit e.g. `"26"`), `next_number` (unsigned int, default 1)
- unique(`program_code`, `year`)

### 4.2 Admission number generator (race safety matters — this is a live public form)

New service, e.g. `App\Services\AdmissionNumberGenerator::next(string $programCode): string`:
- Wrap in `DB::transaction()`, use `->lockForUpdate()` on the counter row (create it first via `firstOrCreate` if missing, then re-select with the lock) to atomically read-then-increment `next_number`.
- Format: `{prefix}/{programCode}{yy}/{str_pad($seq, 3, '0', STR_PAD_LEFT)}` — prefix and current 2-digit year pulled from settings/`now()`.
- **Only ever called once payment is confirmed** (Paystack callback success, or admin manual-verify action) — not at initial form submission. Otherwise abandoned/unpaid applications would burn sequence numbers and create visible gaps, and the whole point of the "plug and play" printable form is that it's the *paid* artifact.

### 4.3 Settings (add to `school_settings`, surfaced in `SchoolSettings.php` Filament page)

- `admission_fee_amount` (default `50`)
- `admission_number_prefix` (default e.g. `Zighis` for this install; must be blank/generic in the shipped default so other schools aren't stuck with someone else's name)
- `admission_programs` (JSON array of `{code, label}}` — for this client: `SCI`/Science, `GA`/General Arts, `VPA`/Visual & Performing Arts, `HE`/Home Economics, `BUS`/Business Studies — **pending confirmation, see §6**)
- `admission_momo_number`, `admission_momo_name`
- `admission_notice_body` — reuse the *existing* `admission_notice_title`/`admission_notice_body` settings already in `SchoolSettings.php` (~line 208) for the "letters/prospectus/rules given at school, bring your parent" popup copy, rather than adding new ones.

### 4.4 Application flow (extends `PublicWebsiteController`)

1. `apply()` — add `admission_programs`, `admission_fee_amount`, `admission_momo_number/name` to the view data (alongside existing `classes`, `admission_custom_fields`).
2. Apply form (Blade view — theme-aware via `themeView()`, check `resources/views/public/apply.blade.php` and any `public/themes/*/apply.blade.php` overrides) — add a **program** select (from `admission_programs` setting) next to the existing `class_id` select.
3. `storeApplication()` — validate `program_code` against the configured list; create the `Application` with `payment_status = 'unpaid'`, **no** `admission_number` yet; redirect to a **new** payment-choice step instead of straight to `applySuccess`.
4. **New payment-choice page/route** (e.g. `GET /apply/{ref}/pay`): shows the fee amount and two buttons —
   - **Pay with Mobile Money (Paystack)** → POST to a new `ApplicationPaymentController::checkout(Application $application)`, cloned from `Payments\PaystackController::checkout()` but keyed on `application_id` in the Paystack metadata instead of `invoice_id`, amount from `admission_fee_amount` setting (not an Invoice). On Paystack callback success (new `ApplicationPaymentController::callback()`): verify, set `payment_status='paid'`, `payment_method='paystack'`, `payment_amount`, `paid_at`, call `AdmissionNumberGenerator`, save `admission_number`, redirect to `applySuccess`.
   - **I've already sent MoMo manually** → small form (sender name + MoMo transaction reference) → sets `payment_status='pending_verification'`, `payment_method='manual_momo'`, `payment_reference`; shows a "we'll verify and email you" message (**no** admission number yet, no printable form yet).
5. `applySuccess()` (paid case only) — show the admission number, a "print/download filled form" action (see §4.5), **and** the popup/notice (admission letter + prospectus + rules given at school; bring a parent) using `admission_notice_title`/`admission_notice_body`. A simple dismissible Alpine.js modal on page load is enough — Alpine already ships with Filament/Livewire, no new JS dependency needed.
6. **Admin verification queue** (manual MoMo path): extend `ApplicationResource` (or add a table action) to list `payment_status = 'pending_verification'` applications with a "Verify Payment" action — staff confirms the MoMo reference against their own momo statement, action then does exactly what the Paystack callback does (mark paid, generate admission number, notify guardian by email — reuse the existing `Application::booted()` status-change email hook, or add a dedicated one for payment confirmation since `status` and `payment_status` are different fields).

### 4.5 "Filled, printable form"

The client's ask: applicant fills the form once online; on completion (i.e., after payment) they get a **pre-filled** printable PDF (not the blank template `downloadApplicationForm()` already produces). Add `downloadFilledApplicationForm(Application $application)` — same `pdf.application-form` Blade view as the existing blank one, but pass the `Application` record's data into it so fields render pre-filled. Gate it behind `payment_status === 'paid'`.

## 5. Fast-follow scope (not Sunday-blocking — propose doing next week)

### 5.1 Results: what `assessments_all_20260812.xlsx` actually is

Inspected with the project's own `phpoffice/phpspreadsheet` (already a dependency). Key findings:

- **49 sheets**, one per Subject × Form combination (e.g. `"Biology – Form 2"`, `"Mathematics – Form 1"`), each a "Continuous Assessment Sheet."
- Header rows 1–7 carry `SCHOOL:`, `SUBJECT:`, `TERM/YEAR:`, `FORM:`, and a `TOTAL STUDENTS` / `Points/Weighting:` row.
- **Row 9 is the real column header row**, and its score-column names are **lowercase and byte-for-byte identical to eduassess's category enum**: `ica1, ica2, icp1, icp2, gp1, gp2, practical, mid_term`, plus `end_term` further right. Other columns: `#`, `Student No.` (an eduassess-generated ID, e.g. `STU2400306060A9` — not a TemseeEdu student ID), `Surname`, `First Name`, `Other Name`, `Reference` (a *different*, shorter eduassess ID, e.g. `STU102348`), **`Study Area/Learning Area`** (the specific elective option, e.g. `"Home Economics C"`, `"Science A"` — confirms Learning-Area-only granularity for admission numbers is a *separate, coarser* concept from this per-student elective tracking).
- Everything past the raw score columns (subtotals, `Total Class Score`, `100% OF TOTAL CLASS SCORE`, `AVG. CLASS SC.`, `%`, `GPA`, `Grade`) is **derived/computed**, not raw input — should be recomputed by TemseeEdu's own grading logic on import, not trusted verbatim from the sheet.

**This is not a simple importer task — it's a data-model gap.** TemseeEdu's current `Result` model (`app-modules/Results`, see `ResultResource/Pages/ListResults.php`) only stores `class_score` (CA, /30) + `exam_score` (/70) — a 2-component model. The client's actual grading (matching eduassess's `ASSESSMENT_WEIGHTS`) is a **9-component weighted model**: `ica1` 5%, `ica2` 5%, `icp1` 5%, `icp2` 5%, `gp1` 5%, `gp2` 5%, `practical` 10%, `mid_term` 10%, `end_term` 50%. To be a faithful "plug and play" replacement, the Results module needs to grow to hold these 9 raw components per student/subject/term (either extend `results` table or a new `assessment_components` table), with the existing weights used to compute the final score/grade — mirroring eduassess's own model almost exactly. This is a real schema/feature decision, needs its own scoping pass, and is **independent of the Sunday admissions deadline**.

- **Student matching problem**: the sheet's `Student No.`/`Reference` are eduassess IDs, not TemseeEdu ones. Import will need to match by name (+ class/form) with an admin review/confirm step before committing — mismatches here are a real risk (wrong grades on wrong student), don't auto-commit blindly.

### 5.2 Pushing results out to eduassess

`EXTERNAL_RESULTS_API_INTEGRATION.md` (in the `eduassess` repo) documents exactly the contract TemseeEdu would need to call:
- `POST /api/v1/assessments/bulk` (bearer API-key auth) — body `{"assessments": [{student_number, category, subject, score, max_score, term, academic_year, session, assessor, comments}, ...]}`.
- `category` must be one of the same 9 keys above (confirms §5.1's model needs to align).
- Needs from the client: a live eduassess base URL and an API key (`APIKey` model in their repo — they'd generate one for us). **Not requested yet** — confirm whether this is even wanted, and by when, before building it.

### 5.3 Other asks from the chat, not yet scoped

- Bulk load/add/drop of class subjects (subject enrollment management) — check `app-modules/Results` or wherever student-subject-enrollment already lives (mentioned in recent commit history: "add ... student subject enrollments") before assuming this needs to be built from scratch.
- Bulk student loading (Excel import) + a manual one-by-one registration option — check whether `maatwebsite/excel` (already a dependency) is already wired up for student import anywhere before building new.

## 6. Open questions — need the client's answer before finishing §4, and before starting §5

1. **Admission number granularity**: confirm it's Learning Area only (`SCI`, `HE`, `GA`, `VPA`, `BUS`) and not per-Option (Science A vs Science B would both be `SCI`). The two given examples are consistent with this reading but don't rule out the alternative.
2. **Results model**: does the client want TemseeEdu to actually adopt the full 9-component CA model (real schema work), or would a simpler "attach the raw Excel per student as a document + let staff read off the final % into the existing class/exam score fields" suffice for now? Very different scope.
3. **eduassess push**: wanted for Sunday, later, or not at all (maybe eduassess is being *replaced* by TemseeEdu rather than synced with)? This changes whether §5.2 matters at all.
4. Bulk course/subject and bulk student tools — priority relative to results, and whether anything already built (see §5.3) covers part of it already.

## 7. Other repo state from this session (unrelated to this feature, but relevant)

- Moved the Laravel app from `app/` subfolder to the repo root (two clean commits), merged to `main`, deleted the now-redundant `filament-4-upgrade`/`develop`/`filament-v3` branches. Repo now has only `main`, locally and on `origin`.
- Patched 24 security advisories across `dompdf`, `guzzlehttp/guzzle`+`psr7`, `laravel/framework`, `league/commonmark`, `phpoffice/phpspreadsheet`, `spatie/laravel-medialibrary` — all safe patch-level bumps within existing constraints, verified app still boots.
- **Hardened the School Edition license system** (`app/Services/LicenseActivationService.php`, `app/Http/Controllers/LicenseServerController.php`, `config/temseeedu.php`, `.env.example`) — it previously had a JWT alg-confusion bug and a `.env`-only "trust me, I'm active" fallback that made License enforcement trivially bypassable by any customer with server access (i.e. every School Edition customer). Now RS256-only, activation-signing endpoint is inert unless `edition=cloud`. **You must generate a real RSA keypair and configure `TEMSEEEDU_LICENSE_PRIVATE_KEY_PATH` on the vendor's own cloud instance for activation to work at all** — nothing has one configured yet.
- Fixed N+1 queries in `HeadmasterOverviewWidget`, `ReportCardResource`'s bulk "Generate Report Cards" action, and `InvoiceResource`'s bulk "Batch Generate Invoices" action.
- **Test suite status is unknown** — two attempts at a full run were interrupted/lost across session boundaries (`php artisan test` shells out to a *separate* phpunit process that doesn't inherit `-d memory_limit` flags passed to the outer command; PDF-generation tests OOM at the default 128M CLI limit). To get a real signal: run `php -d memory_limit=1024M vendor/phpunit/phpunit/phpunit --configuration=phpunit.xml` directly (not via `php artisan test`), against a freshly migrated `temseeedu_testing` database.
- Added an OPcache section to `deployment_guide.md`.

## 7b. Bugs found via actual browser testing (not caught by direct-script testing)

Real browser testing by the client found two bugs that isolated CLI/script testing missed — worth noting because it validates that browser testing is finding real things faster than more isolated tests would:

1. `pdf/application-form.blade.php` used `$application->prop ?? ''` in several places instead of `$application?->prop ?? ''` — fine when filled, fatal `ErrorException` on the blank form path (`/apply/form`, no `$application` at all). Fixed all instances.
2. **The class-matching regex in `AssessmentSheetImporter` was broken for stream-lettered classes** (e.g. real classes named "Form 1A"/"Form 1B", not bare "Form 1") — the `\bform\s*N\b` pattern's trailing `\b` requires a word boundary right after the digit, which doesn't exist between "1" and "A" in "1A". Every "Form 1"/"Form 3" sheet was being skipped as "no class matching," while "Form 2" sheets only *appeared* to work because a leftover test-fixture class named exactly "Form 2" (no letter suffix) was sitting in the tenant DB from earlier testing — a false positive that masked the real bug from my own verification. Fixed the regex (`(?!\d)` negative lookahead instead of `\b`, correctly matches "Form 1A"/"Form 1B" while still rejecting "Form 10"/"Form 12"), and restructured resolution to pool students across *all* matching stream classes for a form (a sheet like "Biology – Form 1" legitimately covers both 1A and 1B) rather than picking just one class — each Result row now takes its `class_id` from the matched student's own class, not a single sheet-level guess. Also added proper handling for the "All Classes" sheets (Music, Arts Design Studio — combined across every form rather than split), which were being skipped entirely before.
   **Lesson for whoever picks this up next**: don't trust a "looks like it worked" result without checking whether it's real data or a leftover fixture from testing — clean up test data immediately after each test, not at the end of a session.

## 8. Suggested build order for §4 (Sunday scope)

1. Migration (§4.1) — additive, low risk, do first.
2. `AdmissionNumberGenerator` service (§4.2) — pure logic, easy to unit test in isolation before wiring into controllers.
3. Settings additions (§4.3) — needed by everything downstream.
4. `ApplicationPaymentController` (§4.4, Paystack path) — clone of the proven `Payments\PaystackController` pattern, lowest risk of the two payment paths since it's automated end-to-end.
5. Manual MoMo path (§4.4) — form + admin verify action.
6. Filled PDF (§4.5).
7. Notice/popup + settings UI polish.
8. End-to-end test: submit as a real applicant, pay via both paths, confirm admission number sequencing under concurrent submissions (open two tabs, submit near-simultaneously for the same program, confirm no collision).
