# CLAUDE.md

Guidance for Claude Code when working in this repo.

## What this is

Multi-tenant K-12 school-management SaaS API. Laravel 12 · PHP 8.2+ · PostgreSQL 16 · schema-per-tenant via `stancl/tenancy` v3 · Laravel Sanctum. The companion frontend lives at `/var/www/html/blouza/k12-edusol` (React 18 + Vite).

The restructure plan in `/home/temitope/.claude/plans/dynamic-launching-kahan.md` drove the rebuild from a single-tenant MySQL codebase into the current shape. `PROGRESS.md` tracks phase-by-phase status; consult it before making architectural assumptions.

## Architecture

### Multi-tenancy (Stancl v3)

- **Landlord schema (`public`)** — platform-wide tables only: `tenants`, `domains`, `subscription_plans`, `subscription_features`, `subscriptions`, `platform_users`, `impersonation_sessions`, `platform_activity_logs`, `payment_invoices_index`.
- **Tenant schema (`tenant_<uuid-with-dashes>`)** — one per institution. Holds everything operational (users, roles, permissions, students, attendance, assignments, invoices, etc.). Tenant isolation is **schema-level**; no `institution_id` columns inside tenant tables.
- One Postgres database (`edusol_k12`), two connections in `config/database.php`: `pgsql` (landlord, `search_path=public`) and `tenant` (re-pointed per request by Stancl).
- Tenants are provisioned via `php artisan institution:provision "<Name>" <slug> <admin-email> [--domain=...] [--plan=...]`. This runs `App\Actions\Landlord\ProvisionInstitution`: creates Institution row → Stancl creates the schema + runs tenant migrations → `database/seeders/Tenant/DatabaseSeeder` runs (210 permissions × 8 system roles × default programs/grade levels/subjects/grade scales/CBT vault/admission stages) → first school-admin user created with a temp password.
- Tenant lifecycle is hooked on the Institution model (`App\Models\Landlord\Institution`).

### Tenant resolution order (middleware `tenant`)

`App\Http\Middleware\IdentifyTenant` runs before `auth:sanctum` (priority forced via reflection in `AppServiceProvider::boot` — Laravel's `prependToPriorityList` isn't reliable once Stancl has registered its own bootstrappers). Resolution order:

1. Subdomain → `InitializeTenancyByDomain`
2. `X-Tenant-Id` header (UUID or slug)
3. `institution_slug` body field (local-dev convenience)
4. `?tenant=<slug>` query param (local-dev convenience)

If tenancy isn't initialized before `auth:sanctum` fires, Sanctum queries the **landlord** `personal_access_tokens` table instead of the tenant copy and auth silently fails. Don't reorder middleware.

### Two auth guards

- **`platform`** → Blouza super-admins (`PlatformUser` model, lives in landlord only). Endpoints under `/api/v1/platform/*` (canonical) and `/api/v1/super-admin/*` (alias). Can impersonate any tenant via `/platform/institutions/{id}/impersonate` which mints a tenant-scoped Sanctum token; tenant's `/auth/me` surfaces `is_impersonating: true` + impersonator info for the banner.
- **`sanctum`** → tenant users (`App\Models\Tenant\User`). All tenant API routes require `tenant` + `auth:sanctum`.

### Dynamic RBAC (rolled custom, not Spatie)

- `config/permissions.php` is the canonical module/tab catalogue (matches `/admin/roles` UI). The seeder expands this into `permissions` rows at tenant provision: 42 module/tab rows × 5 actions (view/create/edit/delete/approve) = 210 permission codes like `academics.curriculum.edit`.
- 8 system roles seeded per tenant (Super Admin, School Administrator, Teacher, Accountant, Registrar, Librarian, Parent, Student). `is_system=true` rows are blocked from edit/delete by `RolePolicy` even when the actor has the right permission bit.
- `HasRoles` trait on User (`app/Models/Tenant/Concerns/HasRoles.php`) gives `hasPermission(code)` and `hasAnyPermission(...codes)`. Middleware `permission:<code>` enforces at the route level.
- **Defense-in-depth:** every write route should have both a `permission:` middleware AND a `$this->guard()` call inside the controller. The middleware catches unauthorized before the controller; the guard covers cases where the middleware is ever bypassed.

### Controller / model / resource layout

- `app/Http/Controllers/Platform/*` — Blouza-only endpoints.
- `app/Http/Controllers/Landlord/*` — public, cross-tenant endpoints (e.g. the CBS webhook + callback router).
- `app/Http/Controllers/Tenant/*` — everything else. One controller per resource.
- `app/Models/Landlord/*` — platform-wide models. Tied to `pgsql` connection explicitly.
- `app/Models/Tenant/*` — per-tenant models. Default connection (Stancl swaps to `tenant` when initialized).
- `app/Http/Resources/Tenant/*` — API resources. Consistent `{data: ...}` response shape.
- **Answer-key / sensitive-field scrubbing pattern:** when a resource needs to hide a field based on caller context, use an explicit `if (! $this->shouldHide)` block in `toArray()` rather than `$this->when()`. `when()` returns a `MissingValue` sentinel that Laravel's response pipeline filters, but it leaks when parent resources call `toArray()` manually. See `CbtQuestionResource` / `AssignmentQuestionResource` for the reference shape.

### Routing files

- `routes/api.php` — tenant API under `/api/v1/*`. Grouped by `['tenant', 'auth:sanctum']` for authenticated, `['throttle:N,1', 'tenant']` for public-with-tenant (admission, CBT, payment).
- `routes/platform.php` — Blouza endpoints under `/api/v1/platform/*` and `/api/v1/super-admin/*`.
- `routes/cbs.php` — CBS webhook + callback at application root (`/cbs/webhook`, `/cbs/callback`). Public, stateless, no tenant middleware (tenant resolved from the invoice number via `payment_invoices_index`).
- All three are registered via `bootstrap/app.php` `then:` hook.

### Key domains (phase → tables)

- **Phase 2** — RBAC: `permissions`, `roles`, `permission_role`, `role_user`.
- **Phase 5** — School setup: `academic_sessions`, `terms`, `programs`, `grade_levels`, `sections`, `subjects` (+ `grade_level_subject` pivot), `klasses` (+ `klass_subject`), `grade_scales`, `grading_strategies`, `institution_settings`, `facilities`, `rooms`.
- **Phase 6** — Users: `students`, `teachers` (+ `teacher_subject`), `parents` (+ `parent_student` pivot), `staff`. All reference `users`/`user_profiles`.
- **Phase 7** — Admission: `admission_forms` (+ `admission_criteria`, `screening_requirements`), `applicants` (+ `applicant_documents`, `applicant_status_histories`), `admission_stages`.
- **Phase 8** — CBT: `cbt_questions`, `cbt_tests` (+ `cbt_test_questions`), `cbt_attempts` + `cbt_attempt_answers` (polymorphic `taker` = Student or Applicant).
- **Phase 9** — Attendance + traits: `attendance` (unique per student+klass+date), `attendance_locks`, `student_subject`, `student_traits`.
- **Payment gateway (a/b/c)** — `invoices`, `payments`, plus admission-fee columns on `applicants` + `admission_forms`. Landlord `payment_invoices_index` for webhook tenant routing.
- **Phase 10A** — Academics core: `assignments`, `assignment_questions`, `submissions`, `submission_answers`, `grades`.
- **Phase 10B** — Curriculum + schedule: `timetable_periods` (school bell schedule), `timetables` (per-klass day×period cells), `lesson_plans`, `schemes_of_work` (weeks JSON), `learning_resources` (file or external link). Learning resource uploads land on the `public` disk under `learning-resources/`. Existing frontend calls `/v1/logistics/timetable/{id}`; that's aliased to `TimetableController::forClass` — keep both paths.
- **Phase 10C** — Term-end + portals: `student_term_remarks` (per-student-per-term narrative + cached aggregates: `subjects_count`, `total_score`, `average`, `gpa`, `position`). MasterSheetService computes weighted totals (CA + Midterm + Exam) using the default grading_strategy, ranks within-subject + overall, writes traits and comments in one transaction. ReportCardService builds a payload matching `src/components/report-card/ReportCardView.tsx` exactly; `resources/views/reports/report-card.blade.php` is the dompdf template. Student/parent portals: `/v1/portal/student/*` + `/v1/portal/parent/*` (parent pivoted via `parent_student` to prevent cross-family access). Frontend alias `/v1/academic/master-sheet/submit` maps to the same handler as `/v1/master-sheet/submit` for compatibility with the existing MasterSheetTab.
- **Phase 11A** — Finance foundation: `fee_types` (seeded catalogue), `fees` (scope columns nullable per dimension — program/grade_level/section/klass/term/session), `fee_payments` (per-student ledger per fee, tracks original/discount/scholarship/due/paid/balance, back-fills `invoice_id` when payable), `installment_rules`, `discounts`, `scholarships` + `scholarship_student`. `FeeAssignmentService::assign(Fee)` walks every matching student and upserts a row (paid/waived rows are left alone). **CBS reuse:** `CbsPaymentService::createStudentFeeInvoice(Student, $feePaymentIds[])` bundles fee_payments into an `EDU-*` CBS invoice; `distributeAcrossFeePayments` spreads settlement across linked rows on webhook. Parent-portal endpoints `/v1/portal/parent/children/{student}/finance` + `/finance/pay` (POST) initiate payment. **Stay on CBS** as the single gateway — no secondary provider is intended.
- **Phase 11B** — Revenue + expense tracking: `revenue_heads` + `revenue_sub_heads` (seeded; sub-heads can link to a `fee_type_id` so incoming payments auto-categorise), `expense_categories` (seeded from CSV), `expenses` (ULID + soft deletes + receipt file upload on the `public` disk, status ∈ {draft, recorded, approved, cancelled}). `FinanceStatsController` serves `/v1/finance/stats` (fees billed/collected/outstanding, collection rate, expense totals, revenue breakdown by invoice purpose, net position) and `/v1/finance/monthly-trend` (N months of revenue-vs-expense; Postgres `date_trunc` output parsed through Carbon so the key match is driver-independent). **Permissions:** `school-admin` gets `finance.view`+`finance.reports.view`; the `accountant` role holds `finance.*` for writes — this separation is deliberate, don't collapse it.
- **Phase 11 deep integration** — Finance is now wired into enrollment / promotion / payment / reporting. Things to know:
  - `fees.revenue_sub_head_id` is the explicit revenue override; fallback is via `fee_type_id → revenue_sub_heads.fee_type_id`. Use `Fee::resolvedRevenueSubHead()` in PHP or the `COALESCE(explicit_sub.id, fallback_sub.id)` SQL join pattern in reports (`RevenueReportService`).
  - `StudentBillingService::bill(Student)` is the reverse walk of `FeeAssignmentService` — the service that bills a student for all currently-active matching fees. Called from `EnrollApplicant`, `StudentController::store`, and `ImportStudents` so every new student is auto-billed. `rebillAfterPlacementChange()` runs from `StudentController::update` when klass/section/grade/program changes; it cancels stale pending bills and adds newly-matching ones. Paid/waived rows are never touched.
  - **Null-scope gotcha** (easy to reintroduce): `orWhere('col', $student->col)` compiles to `OR col = NULL` which is always false in SQL. The billing service only adds the equality branch when the student's value is non-null. Don't reintroduce the plain `orWhere($col, null)` pattern.
  - Past-term fees aren't billed to mid-year enrollees — billing service filters `fee.term_id IS NULL OR fee.term_id >= current_term_id`.
  - `POST /v1/finance/rebill-all` (gated on `finance.fees.approve`) is the catch-up sweep when scholarships/discounts change or a fee is created after students were enrolled.
  - Revenue rollup: `/v1/finance/revenue-by-head` and `/v1/finance/outstanding-by-sub-head`.
- **Phase 11C** — Receipts + statements: `FinanceDocumentService` builds payloads, two blade templates under `resources/views/reports/` (`receipt.blade.php` with PAID stamp, `statement.blade.php` term-sectioned). `FinanceDocumentController` serves JSON + PDF for both. Authz: `finance.view` for staff, student-owner, parent-via-pivot. Portal payloads (`/portal/parent/.../finance`, `/portal/student/finance`) include `invoice_ulid` + `receipt_url` per payment row.
- **Phase 12** — Logistics: `library_books`+`library_logs`, `hostels`+`hostel_rooms`+`hostel_allocations`, `transport_routes`+`buses`+`route_students`, `inventory_items`+`store_transactions`. Routes under `/v1/logistics/*` plus 6 frontend-parity aliases at `/v1/finance/{hostels,transport,library/books,inventory,transactions}`. Permissions: `library.*`, `hostel.*`, `transport.*`, `store.*`. **Concurrency:** library borrow + store sell both call `lockForUpdate()` on the book/inventory row before checking quantity — don't drop these locks under refactor. **Search:** `library_books` (title + author) and `inventory_items` (name) carry `pg_trgm` GIN indexes; the `?search=` query parameter on those endpoints uses `ilike` matched against the trigram set. **`whenLoaded()` is a Resource method, NOT a Model method** — on bare model objects use `relationLoaded('foo')` instead, otherwise you get "Call to undefined method". **`pg_trgm` schema gotcha:** the extension is database-global. Migrations create it `WITH SCHEMA public` and reference `public.gin_trgm_ops` so every tenant search_path can find the operator class. If the extension lands in some other schema (Postgres places it in the first writable schema in search_path), tenants that don't have that schema in their search_path will fail with `operator class "gin_trgm_ops" does not exist`. Fix: `ALTER EXTENSION pg_trgm SET SCHEMA public`.
- **Phase 13** — Communications: `announcements`+`announcement_recipients` (pivot stamps `read_at` per user), `message_threads`+`message_participants` (pivot `last_read_at`/`is_pinned`/`is_muted` for per-user thread state)+`messages`, `notifications`+`notification_settings`. Routes under `/v1/{announcements,messages,notifications}/*`. Permissions: `communications.broadcasts.{view,create,edit,approve,delete}` for broadcasts; `communications.inbox.*` for messages (currently view-only gating — sending is allowed for any participant). Thread identifier on the wire is the **ulid** (string) — `MessageThread::getRouteKeyName()` returns `ulid`, do NOT pass numeric ids in URLs. **Audience resolver** (`AnnouncementBroadcaster::resolveAudience`) supports 5 targets: `all`, `user_type` (csv list), `role` (role-code list), `klass` (students of that klass + their parents via `parent_student`), `grade_level` (same idea, level-wide). The author is excluded from the recipient pivot. **Direct-thread reuse:** `MessageController::start` checks for an existing 2-participant direct thread between the same two users and returns it instead of creating duplicates. Realtime is deferred — frontend polls by reloading `conversations` after each send.
- **Phase 14** — Extras: `houses`+`house_point_logs` (with `students.house_id` FK), `clubs`+`club_student`, `achievements`, `website_configs`+`website_pages` (singleton config row, replace-all-pages snapshot save). Routes under `/v1/{extracurricular,archive,website,logs}/*` plus `/v1/academics/classes` (frontend-parity alias for `/v1/klasses`) and `/v1/public/site` (unauth, tenant-resolved by subdomain or X-Tenant-Id, only returns `status=published`). Permissions: `extracurricular.*`, `alumni.*` (uses `alumni.approve` for the graduation processor), `website-builder.*`, `audit-log.view`. **Graduation processor** flips `students.status='graduated'`, stamps `graduation_year`, optionally deactivates the matching user account — runs in a transaction. **Award-points** is `house_point_logs.create` + `houses.points` increment in one transaction; the cached counter is the source of truth, the log table is the audit trail. **WebsiteController save** is replace-all: each save deletes existing pages and re-inserts the incoming snapshot — frontend should always send the full page array, never partial diffs. **Public site route** sits under a `throttle:60,1` group separate from the auth:sanctum group, so any browser hitting `acme.edusol.app/api/v1/public/site` gets the published config without a token.
- **Phase 15** — Cross-cutting finalization (first pass). **Activity logging is live end-to-end:** `App\Models\Tenant\Concerns\LogsTenantActivity` trait (wraps Spatie's `LogsActivity`) is on 14 tenant models (User, Student, Applicant, Fee, Invoice, Payment, Announcement, House, Achievement, Club, WebsiteConfig, Assignment, Grade, Attendance). Stancl's per-request connection swap means each tenant's events land in its own `activity_log` table. `/v1/logs/activity` and `/v1/logs/stats` now surface real model events with old→new diffs and `causer_id`. **Policies:** `App\Policies\Tenant\BasePolicy` is the default-deny scaffold mapping 5 CRUD methods + `approve` to `module.action` perm codes; concrete subclasses for Student, Applicant, Fee, Invoice, Announcement registered in `AppServiceProvider::$policies` and bound via `Gate::policy()` in `boot()`. Owner/parent-pivot read access is encoded directly in `StudentPolicy::view` and `InvoicePolicy::view`. **FormRequests:** the highest-traffic write paths (`StudentController::store`/`update`, `ApplicantController::update`) lifted into `App\Http\Requests\Tenant\{Store,Update}*Request` classes — they implement `authorize()` against permission codes so the request lifecycle now enforces auth before validation runs. The remaining ~25 controllers either centralize their validators in protected helpers (`validatePayload()`) or use inline `$request->validate()`; the full sweep is incremental polish, not a blocker. **Smoke runner:** `tests/smoke.sh <token> [tenant]` exercises 19 canonical GET endpoints — used instead of in-memory feature tests because the schema-per-tenant Postgres setup needs a real connection.

## Common commands

```bash
# Provision a fresh tenant (runs all migrations + seeders)
php artisan institution:provision "Demo Academy" demo admin@demo.test --domain=demo.edusol.test --plan=premium

# Destroy a tenant (drops schema)
php artisan institution:destroy demo --force

# Landlord migrations only
php artisan migrate --path=database/migrations

# Serve
php artisan serve --port=8000

# Lint
./vendor/bin/pint

# Verify Redis is safe to enable as cache
php artisan cache:verify [--tenant=<slug>]
```

Route inspection:
```bash
php artisan route:list | grep <pattern>
```

Connect to a tenant schema directly (useful for debugging):
```bash
PGPASSWORD=gr8skillz psql -h localhost -U skills -d edusol_k12 \
  -c "SET search_path TO \"tenant_<uuid-with-dashes>\"; SELECT ..."
```

## CBS payment gateway (Coronation Unified Platform)

- Config in `config/cbs.php`, credentials in `.env` (see the `CBS_*`, `IGR_*`, `PROVIDER_*`, `MDA_*` vars).
- **One merchant for the whole platform** — all tenants transact through the same Blouza CBS merchant. The revenue split (IGR/Provider/MDA %) is global; `MDA_ID` + `MDA_NAME` can be overridden per-tenant later via `institution_settings`.
- Landlord HTTP client: `app/Integrations/Cbs/Cbs.php`. HMAC-SHA256 basic auth for the token endpoint, Bearer for everything else, 23h token cache (file-backed at `storage/framework/cache/cbs_token.json` — Stancl's `CacheTenancyBootstrapper` forces a taggable store, which the default `file` driver can't satisfy).
- Tenant business logic: `app/Services/Tenant/Payment/CbsPaymentService.php`. Handles `createAdmissionInvoice`, `createAcceptanceInvoice`, `handleWebhookPayment` (idempotent via `payments.payment_ref + request_reference` unique index), `fetchPayment` (self-heal), `settleInvoiceManually`.
- Invoice prefixes: `ADM-*` (admission app fee), `ACC-*` (acceptance fee), `EDU-*` (student fee invoices — reserved for Phase 11).
- Webhook / callback flow: CBS POSTs to `/cbs/webhook` (single URL). Landlord `CbsWebhookController` reads `payment_invoices_index` to find the owning tenant, calls `Tenancy::initialize($institution)`, then delegates to the tenant service. Callback URL `/cbs/callback` redirects the browser to `{FRONTEND_URL}/payment-status?status=...&reference=...&tenant=<slug>` so the frontend lands in the right tenant context.

## Mail (transactional)

- Transport: Mailgun SMTP via `MAIL_*` in `.env`. Test: `php artisan mail:test --to=<addr>`.
- Three notifications in `app/Notifications/`: `UserCredentialsNotification` (reusable welcome + temp password), `CbtAssignedNotification` (applicant exam token + portal URL), `PaymentReceiptNotification` (post-settlement receipt).
- **Every user-creation site funnels through `App\Services\Tenant\Notifications\UserAccountMailer::sendWelcome()`** — one funnel for copy, throttling, queue behaviour. Dispatched sites: `ProvisionInstitution`, `EnrollApplicant` (student + parent when new), the 4 controller `::store` methods, the 4 bulk-import actions, `AssignCbtToApplicant`, `CbsPaymentService::handleWebhookPayment` + `::settleInvoiceManually`.
- **Rule:** mail dispatches must sit inside `DB::afterCommit(...)` so a transaction rollback doesn't send a spurious email. The `UserAccountMailer` already wraps its send in try/catch + `Log::warning` so SMTP failures never break the source flow.
- Welcome mails point users at `config('cbs.frontend_url').'/login'` — that env var is shared with CBS callback URLs intentionally.

## Caching

- Default `CACHE_STORE=file`. Do NOT call `Cache::put`/`Cache::get` inside tenant-initialized code paths under this driver — Stancl's `CacheTenancyBootstrapper` wraps the store in a taggable proxy and the file driver will throw "This cache store does not support tagging."
- To enable Redis: set `REDIS_PASSWORD` in `.env`, run `php artisan cache:verify --tenant=<slug>`, then flip `CACHE_STORE=redis` once all three checks pass.

## Conventions

- **Response shape:** `{ data: ..., message?: "...", meta?: { pagination: ... } }`. Frontend code often needs `.data.data` unwrapping — don't skip the outer `data` key.
- **IDs:** every user-facing entity has both a numeric `id` (primary key) and a `ulid` (for external URLs, QR codes, route keys). Models override `getRouteKeyName()` to return `ulid` where appropriate.
- **Route binding:** implicit binding resolves on `ulid` for Applicant/Assignment/CbtTest/CbtAttempt/Invoice/Payment/User. Numeric-id binding for everything else.
- **Soft deletes** on: `institutions`, `users`, `students`, `applicants`, `assignments`, `invoices`, `admission_forms`.
- **Timestamps:** ISO 8601 UTC on the wire. Frontend formats for display.
- **No comments for obvious code.** Only write a comment for a non-obvious constraint, a workaround, or a surprise. Default is zero comments. See the existing codebase for the style — block comments are reserved for module-level explainers at the top of services/migrations.
- **Code organization bias:** one controller per resource, FormRequest per non-trivial write (Phase 15 pass will extract the remaining inline `$request->validate()` calls), API Resource per entity, Policy per authz-bearing model.
- **Don't break schema-per-tenant isolation.** Never add `institution_id` columns to tenant tables — the schema IS the scope. If you need a cross-tenant aggregate, add a landlord-side index table (see `payment_invoices_index` for the pattern).

## Where things live

| Concern | Location |
|---|---|
| Plan | `/home/temitope/.claude/plans/dynamic-launching-kahan.md` |
| Phase status | `PROGRESS.md` |
| CSV seed data | `database/data/{defaults,landlord,demo,setup-templates}/` |
| Permission matrix | `config/permissions.php` |
| Tenant base seeder | `database/seeders/Tenant/DatabaseSeeder.php` |
| Frontend | `/var/www/html/blouza/k12-edusol` |
| Reference CBS impl | `/var/www/html/blouza/edusol-backend` + `/var/www/html/blouza/edusol-frontend` |
