# B2B Multi-Tenancy Design — AllGifted Math (AGS Math Tutor)

**Date:** 2026-06-06
**Author:** research/design pass (read-only — no schema, migration, or prod DB change)
**Status:** Design / RFC. Decisions for Pam at the bottom.
**Scope:** How to take the single-tenant B2C math backend and grow a school /
tuition-centre / partner (B2B) layer on top **without re-sharding the maxile
cascade** and **without abandoning the dormant `house` machinery** that already
models class / roster / seats / join-code / per-seat entitlement / curriculum.

> Conventions honoured (CLAUDE.md + memory): Sanctum guard is explicit in API
> controllers; admin/teacher login is OTP-only (never email+password); Stripe
> webhook signature verification stays mandatory; no prod commits; mail config
> stays in `.env`. This doc changes nothing — it is a plan.

---

## 1. Current state (grounded in code)

### 1.1 `house_id = 1` is a binary entitlement flag, not a tenant

The OTP login path treats "math access" as one global membership row in
`house_role_user` keyed to a **hardcoded** house and role:

```php
// app/Http/Controllers/OTPController.php — checkMathEnrollment()
return DB::table('house_role_user')
    ->where('user_id', $user->id)
    ->where('house_id', 1)      // ← single global house
    ->where('role_id', 6)       // ← single global "Student" role
    ->where('payment_status', '!=', 'cancelled')
    ->first();
```

`loginResponse()` then derives `has_math_access` purely from that row's
`expiry_date`:

```php
$hasAccess = $mathEnrollment && $mathEnrollment->expiry_date >= now()->toDateString();
```

So `house_id = 1` is being used as **"the math product"**, not as a class. There
is exactly one house and everyone who has access is a row under it.

### 1.2 `createMathEnrollment()` is dead code

`OTPController::createMathEnrollment()` (the only thing that would *write* a
`house_role_user` enrolment, with `places_alloted`, `ACTIVE_TELCO`,
`expiry_date = +1 month`, etc.) is **defined but never called** anywhere in the
controller or codebase. Nothing in the live OTP flow inserts enrolment rows. The
SIMBA/telco entitlement that the method's body imagines is actually carried on
the `users` table instead (`partner_id`, `access_type`, `subscription_plan_id`)
via `Partner` + the Stripe/SubscriptionService path.

### 1.3 SSO ignores houses entirely

`app/Http/Controllers/API/SsoController.php` (the live `account.allgifted.com`
bridge) upserts the user by `external_id → email → phone_number`, mints a
Sanctum token, and reports `is_subscriber` from **`users.access_type`**:

```php
'is_subscriber' => $user->access_type === 'premium',
```

It never reads or writes `house_role_user`. New SSO users get
`access_type = 'free' | 'premium'` (from the `is_premium` claim, creation-only)
and `lives = 5`. Entitlement is a **user-column** concept here, not a house
concept.

### 1.4 Progress is user-global, NOT per-house

`Question::processProgressFor()` delegates straight to the cascade:

```php
// app/Models/Question.php
public function processProgressFor($user, $correct, $test = null)
{
    return app(\App\Services\Maxile\MaxileCascade::class)
        ->run($user, $this, (bool) $correct, $test);
}
```

`MaxileCascade::run()` writes only `(user_id, skill_id)` / `(user_id, track_id)`
/ `(user_id, field_id, month_achieved)` rows and finally `users.maxile_level`.
There is **no `house_id` anywhere in the cascade** — every pivot
(`skill_user`, `track_user`, `field_user`, `kiasu_field_progress`,
`maxile_snapshots`) is keyed by `user_id`. Mastery is an attribute of the
*learner*, independent of which class they sit in.

> **This is the single most important fact for the whole design.** Because
> progress is user-global, a teacher view can be assembled at **read time** by
> intersecting (a) the house roster (`house_role_user` for that class) with
> (b) the class curriculum (`house_track`) against the already-existing
> per-user pivots. We do **not** need to re-shard or duplicate the cascade per
> tenant. Tenancy is a *read-scoping* problem, not a *write-sharding* problem.

### 1.5 Auth / session model

- `config/sanctum.php`: token expiration `60*24*30` (30 days); stateful domains
  from `SANCTUM_STATEFUL_DOMAINS`. Tokens are SHA-256 hashed (per CLAUDE.md) so
  they survive APP_KEY rotation.
- `config/cors.php`: explicit prod origins `allgifted.com`, `mathapi.*`,
  `quiz.*` (Flutter web FE), `account.*` (SSO); `supports_credentials = true`;
  localhost patterns gated to non-prod `APP_ENV`.
- `config/session.php`: DB-driver sessions (`SESSION_DRIVER`, default
  `database`; prod currently `file`/480 per memory), 480-min lifetime. Sessions
  matter only for first-party/web; the Flutter app is pure Bearer-token.
- API controllers resolve the user via the constructor-middleware pattern
  (`Auth::guard('sanctum')->user()` into `$this->user`) — e.g.
  `PaymentController`, `MathAtlasController` (`$request->user('sanctum')`).

### 1.6 Billing: individual subscriptions only

- `PaymentController` creates **per-user** Stripe Checkout sessions
  (`mode=subscription`, `quantity => 1`) and PaymentIntents; amount/name/currency
  are derived server-side from the `subscription_plans` row (client cannot supply
  amount).
- `StripeWebhookController::handle()` (routes `POST /api/stripe/webhook`)
  verifies the signature (`\Stripe\Webhook::constructEvent($payload, $sigHeader,
  $secret)`) before doing anything — keep this invariant.
- `SubscriptionService::handleSubscriptionUpdate()` flips `users.access_type`
  to premium and stores the sub window. Webhook + the FE `verify-session` path
  both funnel through this one method (idempotent).
- Partner/telco (SIMBA) is modelled on `Partner` (`code`, `phone_prefixes`,
  `access_type`, `billing_method`, `features`) and stamped onto the **user**
  (`partner_id`, `partner_subscriber_id`, `access_type`).

### 1.7 Feature gating today

`FeatureAccessService::canAccess()` + `AccessControlService` resolve features off
`users.subscription_plan_id` against `feature_limits`/`feature_usage_logs`. This
is **plan-centric**, with no concept of "who is paying for this seat". That's the
gap the EntitlementService (§4) closes.

### 1.8 Admin surface

Filament panel exists (`app/Filament/Resources/*`: Question, Skill, Track,
Field, Level, Difficulty, User, Status, TestType, TestType) and panel access is
role-gated in `User::canAccessPanel()` to roles `[1,9,10,11]`. There is **no**
House/Class/Organization/Teacher resource yet — Filament today is a content-ops
tool, not a teacher tool.

### 1.9 Taxonomy (shared, global content)

`fields → tracks → levels` with `skill_track` joining skills to tracks; "active"
is `status_id = 3` everywhere (`Field::scopePublic`, `Track::scopePublic`, the
cascade's `status_id = 3` filters). The active fields are the small live set
(the K-6 strands; cascade explicitly filters out non-active fields so legacy
taxonomy rows don't inflate `users.maxile_level`). **This content is the same
for every tenant** — a P3 fraction skill is the same skill whether the learner
is B2C, in a tuition centre, or in a school.

---

## 2. Target model

Introduce an **`organizations`** tier *above* the existing `house`. Reuse
`house` as the **class** unit (rename only in B2B-facing UI — see Decision 4).

```
organizations            (school | centre | partner | b2c)   ← NEW
   └── houses             (= a CLASS / cohort)                ← EXISTS, add org_id
         └── house_role_user  (roster: student/teacher + seat entitlement) ← EXISTS
   └── memberships        (org-level people NOT tied to one class)         ← NEW (small)
   └── house_track        (per-class curriculum window)        ← EXISTS
```

### 2.1 `organizations` (new)

| column | purpose |
|---|---|
| `id` | PK |
| `type` | `enum('school','centre','partner','b2c')` |
| `name` | display name |
| `slug` | URL/login routing (school subdomain, centre code) |
| `partner_id` | nullable FK → `partners` (telco/SIMBA generalisation) |
| `billing_mode` | `enum('b2c_stripe','seat_stripe','manual_licence','partner')` |
| `status_id` | reuse `statuses` (3 = active) |
| `settings` | JSON (branding, feature overrides, default plan) |
| timestamps | |

A single seeded **`b2c` org** (id 1) becomes the home of every existing user, so
step (a) of the migration is behaviourally inert (§8).

### 2.2 `houses` gains `org_id` (one column add)

```sql
ALTER TABLE houses ADD COLUMN org_id BIGINT UNSIGNED NULL AFTER id;  -- FK → organizations
```

The existing global `house_id = 1` row becomes `org_id = 1` (the b2c org). A
tuition centre with three P3 classes is one `organizations` row + three `houses`
rows, each with its own `house_track` curriculum and `house_role_user` roster.
**Everything `House.php` already exposes is reused**: `enrolledStudents()`,
`teachers()`, `tracks()` / `current_track()`, `mastercode` (join code),
`places_alloted` (seats), `valid_tests()`.

### 2.3 `memberships` (new, small)

Only for people **not** anchored to a single class: a principal, an org admin, a
floating/relief teacher who roves across classes. Students and class teachers
stay in `house_role_user` (already role-aware via `role_id`).

| column | purpose |
|---|---|
| `org_id` | FK → organizations |
| `user_id` | FK → users |
| `role` | `enum('owner','admin','principal','floating_teacher')` |
| `status_id` | |
| timestamps | |

This keeps the common case (teacher of *a class*) in the existing pivot and
avoids forcing every roster relationship through a second table.

### 2.4 What we deliberately do NOT change

- The cascade. No `org_id`/`house_id` on `skill_user`/`track_user`/`field_user`.
- `users` identity columns (`external_id`, `access_type`, Stripe fields).
- The taxonomy. Content stays global.

---

## 3. Tenant scoping seam

**Decision: single DB + row-level `org_id`, NOT db-per-tenant.** Rationale: the
shared content catalogue (fields/tracks/skills/questions) is the bulk of the
data and is identical across tenants; per-tenant DBs would force catalogue
replication and break the one-cascade-for-everyone property. We need *read
isolation of rosters/entitlements*, which a row-level scope gives cheaply.

### 3.1 Table classification

**GLOBAL (shared, no `org_id`, never scoped):**
`fields`, `tracks`, `levels`, `skill_track`, `skills`, `questions`,
`difficulties`, `statuses`, `subscription_plans`, `feature_limits`, `features`.

**TENANT-SCOPED (carry/derive `org_id`):**
`organizations`, `houses` (`org_id`), `house_role_user` (via `house_id →
org_id`), `house_track` (via house), `memberships` (`org_id`), tests/quizzes
created *for a class* (via house pivots), and any class-level entitlement/billing
records.

**USER-GLOBAL (keyed by `user_id`, read-scoped at query time, NOT row-tagged):**
`skill_user`, `track_user`, `field_user`, `kiasu_field_progress`,
`maxile_snapshots`, `user_question` answers. A teacher reads these *filtered by
the roster of their class* — the rows themselves are not tenant-owned because a
learner can belong to a school class AND keep a B2C identity. The learner owns
their progress; the org borrows a read view of it.

### 3.2 Resolving "current org"

Reuse the constructor-middleware pattern the API already standardises on:

```php
// proposed: ResolvesOrganization trait, used in B2B controllers
public function __construct()
{
    $this->middleware('auth:sanctum');
    $this->middleware(function ($request, $next) {
        $this->user = Auth::guard('sanctum')->user();
        $this->org  = app(OrgContext::class)->resolve($this->user, $request);
        return $next($request);
    });
}
```

`OrgContext::resolve()` picks the org from (in order): an explicit
`X-Org-Id` / route param validated against the user's memberships+rosters → a
token ability (`org:{id}` scope, see Decision 1) → the user's sole org → the
`b2c` org as fallback. A global Eloquent scope (`BelongsToOrg`) on
tenant-scoped models reads `OrgContext` so queries are filtered without every
call site remembering to `where('org_id', …)`.

> Learner-facing endpoints (`/api/me/math-atlas`, answer submission, kiasu) stay
> **org-agnostic** — they operate on `$this->user` only. Tenancy applies to the
> *teacher/owner* read surface, not the play loop.

---

## Cohort isolation & multi-org students

This section locks the structural rule that makes the §3 `org_id` row-scope
sound: **a cohort never spans organisations**, and **a student is a single
global identity that joins cohorts by membership rows, not by being copied**.
Everything here is grounded in the existing `house` / `house_role_user` /
`house_track` schema (`database/migrations/2015_11_02_074642_house_table.php`,
`2025_05_14_124620_add_plan_to_house_role_user_table.php`) and the user-global
cascade (`app/Services/Maxile/MaxileCascade.php`).

> **Terminology.** "Cohort" = the B2B-facing word for a `house` (= a *class*,
> per Decision D4). One `house` row = one cohort. Used interchangeably below.

### C.1 Data model

**A cohort is a `house`, owned by exactly one org.** The `houses` table gains a
single `org_id` column (§2.2). There is **no** join table between `houses` and
`organizations` — the relationship is one org → many houses, enforced by the FK,
so a cohort **cannot** belong to two orgs. Cross-org sharing of a cohort is not
representable in the schema; that is the isolation guarantee made structural
rather than enforced in code.

```
organizations (1) ──< houses.org_id (N)        each cohort: exactly one org
houses (1) ──< house_role_user.house_id (N)     roster: students + class teachers
houses (1) ──< house_track.house_id (N)         that cohort's curriculum window
organizations (1) ──< memberships.org_id (N)    org-level people, not in any one cohort
```

**`house_role_user` is the per-cohort membership.** It already exists with the
exact shape we need (verified in the 2015 migration):

| column | role in cohort isolation |
|---|---|
| `house_id` | which cohort (→ `houses.org_id` → which org) |
| `role_id` | Student (6) / Teacher / etc. within *this* cohort |
| `user_id` | the global learner/teacher identity |
| `plan_id`, `expiry_date`, `start_date`, `payment_status`, `places_alloted`, `amount_paid`, `currency_code`, `purchaser_id` | **per-(user, cohort) seat entitlement** — entitlement is scoped to the membership, not the user |
| `mastercode` | per-row join code (UNIQUE) |
| `progress` | legacy per-membership counter (not the cascade) |

**Keys (as built):**
- **Composite PRIMARY KEY** `(house_id, role_id, user_id)` — so the *same* user
  can hold many rows: different `house_id` (different cohorts/orgs) and even the
  same `house_id` under a different `role_id`. The uniqueness is per
  (cohort, role, user), not per user.
- A surrogate `id INTEGER NOT NULL UNIQUE AUTO_INCREMENT` (added by raw
  `ALTER TABLE`) for Eloquent/FK convenience.
- `mastercode INTEGER UNIQUE`.

A student therefore **holds one `house_role_user` row per cohort they are in**.
Two cohorts ⇒ two rows. There is no shared/merged cohort row across orgs.

**`memberships` (new, §2.3) is org-level, not cohort-level.** It is *only* for
people not anchored to a single cohort — principals, org admins, floating/relief
teachers. Shape: `(org_id, user_id, role enum('owner','admin','principal',
'floating_teacher'), status_id, timestamps)`. Recommended uniqueness:
**UNIQUE `(org_id, user_id)`** (one org-level role per person per org; if a
person needs two org roles, widen the enum rather than add rows). Students and
class teachers are **not** in `memberships` — they live in `house_role_user`,
which is already role-aware.

> Note on existing relations: `User::houses()` is a stale
> `hasMany(House::class, 'house_id')` (wrong FK direction) and is **not** the
> membership path. The real per-cohort membership relations are
> `User::houseRoles()` / `activeHouseRoles()` / `roleHouse()` and
> `House::enrolledUsers()` / `enrolledStudents()` / `teachers()`, all keyed on
> `house_role_user`. B2B code builds on those, not on `User::houses()`.

### C.2 Isolation guarantees

The guarantee: **a teacher/admin in org A can never read org B's roster,
cohorts, or entitlements**, because every tenant-scoped read derives `org_id`
from the cohort and is filtered by the `BelongsToOrg` global scope (§3.2)
against the resolved `OrgContext`.

Concretely, the chain is:
1. A teacher's reach is the set of `house_role_user` rows where
   `user_id = teacher`, `role_id = Teacher` (plus any `memberships` rows). Each
   such row names a `house_id`, and each `house.org_id` is fixed to one org.
2. `OrgContext::resolve()` (§3.2) pins the *current* org for the request.
3. The `BelongsToOrg` scope on `houses` / `memberships`, and the
   `house_id → org_id` derivation on `house_role_user` / `house_track`, filter
   every query to that org. A roster query for a cohort in org B never matches,
   because that cohort's `org_id` ≠ the resolved org, and the teacher holds no
   membership row pointing into org B's houses anyway.

Because a cohort cannot span orgs (C.1), there is no row that is "half in A,
half in B" to leak through. Isolation is **structural** (schema-enforced
one-org-per-cohort) reinforced by the **runtime scope** (org-filtered queries).

**Shared vs isolated — the line:**

| Concern | Status | Why |
|---|---|---|
| Questions, skills, tracks, fields, levels, difficulties, statuses | **GLOBAL / shared** | One catalogue for everyone (§1.9, §3.1). A P3 fraction skill is the same skill in every org. |
| Rosters (`house_role_user`), cohorts (`houses`), curriculum (`house_track`), org-level people (`memberships`) | **ISOLATED per org** | Tenant-scoped by `org_id`; never cross-readable. |
| Entitlements / seats (`house_role_user.*` seat columns, org `licences`, partner state) | **ISOLATED per (user, org/cohort)** | A seat in org A is one membership row; it grants nothing in org B (C.4d). |
| Teacher analytics / dashboards (§7) | **ISOLATED (read-scoped)** | Built as read-time joins over global progress filtered to *this* cohort's roster + curriculum. |
| Learner progress pivots (`skill_user`, `track_user`, `field_user`, `kiasu_field_progress`, `maxile_snapshots`, `users.maxile_level`) | **USER-GLOBAL** | Keyed by `user_id` only; the learner owns mastery, orgs borrow a read view (C.3). |

### C.3 Progress semantics — one maxile, many read-scopes

Progress stays **user-global** (Decision D2). The cascade writes only
`(user_id, …)` rows — confirmed: `MaxileCascade::run()` and every private step
(`updateSkillUser`, `updateTrackUser`, `updateFieldUser`, `updateUserMaxile`,
`advanceKiasuCursor`) key exclusively on `user_id`; there is no `house_id` or
`org_id` anywhere in the file, and `users.maxile_level` is a single per-learner
scalar. A student has **one** mastery state regardless of how many cohorts they
sit in.

A cohort's teacher view does **not** own or fork that state — it **read-scopes**
it to the cohort's `house_track` curriculum. The §7 read-models all take the
same shape: `house_role_user` (roster of this cohort) ⋈ the user-global pivots,
constrained to the tracks in this cohort's `house_track`. The numbers a teacher
sees are *filters over the learner's global mastery*, not cohort-private values.

**What this means for a dual-org student (e.g. centre + school):**

- Both teachers read the **same underlying mastery** (same `skill_user` /
  `track_user` / `field_user` rows). There is exactly one maxile for the child.
- Each teacher sees it **filtered to their own cohort's `house_track`** tracks.
  If the two cohorts teach different track sets, the two views differ in
  *coverage* but agree on any track they share.
- Practice the child does in the centre (or at home as B2C) moves the same
  global pivots, so **the school teacher will see gains that were driven
  elsewhere**, and vice versa. There is no per-cohort "sandbox" of progress.

For K-6 this is acceptable and arguably desirable: mastery is a fact about the
child, the parent app depends on "one learner, one Atlas", and per-cohort
sharding would duplicate the cascade and double write cost (D2). **But the
cross-org visibility of *where* gains came from is a privacy/product decision,
not a technical one — see D5.**

### C.4 Edge cases

**(a) Same student, two cohorts in the SAME org.**
Two `house_role_user` rows, same `user_id`, different `house_id`, both with
`org_id = X`. Allowed by the composite PK. Each cohort's teacher sees the child
filtered to that cohort's `house_track`. One seat per membership row (so the org
may be consuming two seats for one child unless the org's seat policy
de-duplicates by `user_id` — a billing-policy choice for EntitlementService
source #2, not a schema constraint).

**(b) Student across two orgs (centre + school).**
Two cohorts, two `house_role_user` rows, **different `org_id`** (one per org) —
exactly the premise being locked. One global `users` row; two memberships; two
seat entitlements; one shared global mastery (C.3). No shared cohort record.

**(c) What each teacher sees.**
Only their own org's cohort: their roster, their cohort's curriculum coverage,
and the child's global mastery filtered to it. Neither teacher can enumerate the
other org's roster or even learn that the other cohort exists — the
cross-org-leak query returns nothing (C.2). They *can* both observe the same
child's maxile moving (C.3 / D5).

**(d) Entitlement per (user, org/cohort).**
Entitlement is the membership, not the user. An active seat
(`payment_status IN ('paid','active')`, `expiry_date >= today`,
within `places_alloted`) on a cohort in org A grants access **only** through
that membership. It does **not** grant access in org B — org B access requires
its own `house_role_user` seat (centre), org `licence` (school PO), partner
state, or the learner's own B2C Stripe `access_type`. EntitlementService (§4)
resolves `for($user, $org)` precisely so the answer is org-specific: the same
user can be entitled in A and not in B. (The legacy `house_id = 1` "math
product" row, §1.1, is just the B2C org's single cohort under this model.)

**(e) Interaction with D1 (token-bound org vs switch-org).**
A single-org student carries a token bound to that org (`org:{id}` ability) — no
ambiguity. A **dual-enrolled** student is the multi-org minority D1 calls out:
their token must either be re-issued per org on an explicit `switch-org` call,
or carry multiple `org:{id}` abilities with `OrgContext::resolve()` picking the
one named by `X-Org-Id`/route. Either way, **a request is always resolved to one
org**, and the `BelongsToOrg` scope filters to it — so even a dual-enrolled
student's teacher-surface request reads exactly one org's cohorts at a time.
Note this only affects users who also have a *teacher/owner* surface; the
learner play loop is org-agnostic (§3.2) and unaffected by dual enrolment.

### C.5 New open decision surfaced

**D5 — Cross-org progress visibility (privacy).**
*Problem:* because progress is user-global (C.3), a teacher in org A sees a
child's maxile move from practice done in org B (or at home as B2C) — e.g. a
school teacher sees gains a tuition centre's drilling produced, or a centre sees
a school's. The *magnitude* is visible even though the *roster/source* is
isolated. No code change has happened; this is a policy gap the cohort model
exposes.
*Recommendation:* **accept user-global progress as the default for K-6** (keeps
"one learner, one Atlas"; no cascade sharding), and treat cross-org *source*
attribution as out of scope — teachers see mastery, not a provenance ledger of
which org drove each gain. If a school or MOE SLS onboarding later **requires**
that school-earned progress be walled from the home/B2C identity (or that a
centre cannot infer a child's school progress), that is a **policy toggle**
(e.g. an org `settings.progress_isolation` flag that, when set, makes that org's
teacher views read only progress attributable to sessions within the org's
cohorts) — *not* a reason to shard the cascade by default. Flag D5 to Pam as a
product/privacy decision before the first school (step e) onboards; it does not
block centre tenancy (step c).

---

## 4. EntitlementService — one resolver

Today entitlement is computed three incompatible ways: OTP reads
`house_role_user` (`house_id=1`), SSO reads `users.access_type`, feature gating
reads `users.subscription_plan_id`. **Collapse these into one service** that
returns a normalised entitlement and route every access check through it.

```php
final class EntitlementService
{
    public function for(User $user, ?Organization $org = null): Entitlement;
    // Entitlement: { active:bool, source:string, plan:?string,
    //                expires_at:?Carbon, features:array, seat:?object }
}
```

Resolution order (first active source wins), each grounded in an existing data
source:

1. **b2c (existing Stripe)** — `users.access_type === 'premium'` +
   `subscription_end_date`. The path `SubscriptionService::handleSubscriptionUpdate`
   already populates. *(This is the only source wired in migration step b.)*
2. **centre-seat** — an active `house_role_user` row for a house whose `org_id`
   is a centre: `payment_status IN ('paid','active')` AND `expiry_date >= today`
   AND the class is within `places_alloted`. These columns **already exist** on
   `house_role_user` (`places_alloted`, `expiry_date`, `payment_status`,
   `plan_id`). This is exactly what `createMathEnrollment()` was going to write —
   we revive that writer (§8c).
3. **school-PO-licence** — a manual `licences` record (org-level seat pool +
   expiry) created by an admin against a purchase order. No card; finance-driven.
4. **partner** — generalise the SIMBA path: `users.partner_id` →
   `Partner.access_type`/`features`, OR `organizations.type='partner'` with a
   partner billing mode. This subsumes `checkTelcoSubscriber` /
   `ACTIVE_TELCO`.

Then `FeatureAccessService::canAccess()` becomes a thin consumer:
`EntitlementService::for($user, $org)->features` replaces the direct
`subscription_plan_id → feature_limits` lookup, so a centre/school plan can grant
features without minting a personal Stripe plan per learner.

---

## 5. Auth

- **Teacher / owner login = the existing OTP flow.** `OTPController::sendOtp` /
  `verifyOtp` already find-or-create by email/phone, issue a 6-digit code, and
  mint a Sanctum token. A teacher is just a user whose `house_role_user.role_id`
  is a Teacher role (or a `memberships.role` of admin/principal). **No passwords,
  no new admin auth surface** — per the OTP-only invariant. Filament teacher
  dashboards (Decision/§8d) must also route through OTP, not email+password.
- **Org binding at login.** After OTP verify, if the user has exactly one org →
  bind it (token ability `org:{id}`); if several → return an org picker payload
  (mirrors the existing `requires_profile_completion` pattern) and let the FE
  call a `switch-org` endpoint (Decision 1).
- **SSO as a later provider.** `SsoController` already proves the JWT-exchange
  pattern (issuer/audience checks, upsert by `external_id`). Google for Education
  and MOE SLS slot in as **additional issuers/providers** behind the same
  exchange shape — each maps an external identity to a local user and (for
  school SSO) to an `organizations` row via the email domain / SLS school code.
  Defer to migration step (e); identity-only bridge, entitlement still owned by
  EntitlementService.

---

## 6. Billing

> **SUPERSEDED (2026-06-11) — the centre-seat row below was replaced by the
> org-pool model, now the ratified design (the code already implements it).**
> The original §6 had the webhook *fan out* directly to per-student
> `house_role_user` rows. That coupled "seats owned" to "seats assigned" — the
> wrong join. The shipped model decouples them:
>
> 1. **Purchase → pool.** `customer.subscription.{created,updated,deleted}` with
>    `metadata.type='centre_seats'` → `OrgBillingService::reconcileFromSubscription`
>    sets the **org-level pool** (`organizations.seats_purchased` / `seats_expiry`)
>    and writes a `SeatTransaction` ledger row. The webhook touches **no roster
>    rows**. Signature verification stays mandatory (`constructEvent`).
> 2. **Assignment → drawdown.** An admin assigns a student via `EnrolmentService`,
>    which writes the `house_role_user` seat row (`payment_status='active'`,
>    `expiry_date` defaulting to the pool's `seats_expiry`) and is **cap-enforced**:
>    it throws on "no active seat pool" and when `seatsUsed() >= seats_purchased`.
> 3. **Access → entitlement.** `EntitlementService::centreSeat()` reads that
>    `house_role_user` row (active `payment_status` + `expiry_date >= today`).
>
> So the loop closes purchase → pool → cap-enforced assignment → entitlement,
> with seats-owned and seats-assigned as independent quantities. The table row and
> implementation notes below are kept for history; read them through this note.

| Tenant type | Mechanism | Where it lands |
|---|---|---|
| **B2C** | existing per-user Stripe subscription (`quantity=1`) | `users.access_type`, `subscription_*` (unchanged) |
| **Centre** | ~~**seat-based Stripe** — one subscription on the org's Stripe customer with **`quantity = N` seats**; webhook fans out to `house_role_user` rows (`places_alloted`, `expiry_date`, `payment_status`)~~ → **org seat-pool** (`organizations.seats_purchased`/`seats_expiry`), drawn down by cap-enforced `EnrolmentService` assignment (see note above) | centre org pool + class rosters |
| **School (PO)** | **manual `licences` record** — admin enters seat count + term + expiry against a purchase order; no card | org-level licence pool, consumed by EntitlementService source #3 |
| **Partner (telco)** | partner entitlement — generalised SIMBA; no Stripe | `Partner` + `users.partner_id` / partner org |

Implementation notes:

- Seat billing rides the **same `StripeWebhookController`**; it handles
  `customer.subscription.{created,updated,deleted}` quantity changes and maps the
  org's Stripe customer → org → **pool** (`seats_purchased`/`seats_expiry`), *not*
  `places_alloted` on roster rows (superseded — see the note above). **Signature
  verification stays mandatory** (`constructEvent`) — no bypass, Stripe CLI for local.
- Server still derives price from DB rows (the existing anti-tamper invariant in
  `PaymentController` — client never supplies amount).
- A centre admin's "add 5 seats" is a Stripe `quantity` change, not 5 individual
  checkouts. Idempotency keys already in place (`cust-{id}`, `sub-session-…`).

---

## 7. Teacher analytics (class-centric read-models)

All of these are **read-time joins** over user-global pivots filtered by the
class roster (`house_role_user` for the house) and the class curriculum
(`house_track`). No new write path.

1. **Class KPIs** — roster size (`House::enrolledStudents`), active learners
   (answered in window), avg `users.maxile_level`, seats used vs `places_alloted`.
2. **Curriculum pass-rates** — for each track in `house_track`, % of roster with
   `track_user.track_passed = 1` (and avg `track_maxile`). Reuses the exact
   booleans the cascade maintains.
3. **Student × track mastery matrix** — roster (rows) × `house_track` tracks
   (cols), cell = that learner's `track_user.track_maxile` / pass flag. This is a
   single join: `house_role_user ⋈ track_user` constrained to `house_track`.
4. **Attention list** — learners with low/falling maxile. The cascade is
   non-monotonic (maxile can drop after a weak run) and `maxile_snapshots`
   already stores the longitudinal series, so "falling" is computable directly.
5. **Drill-in** — reuse the parent app's per-student Atlas component. The backend
   shape already exists: `MathAtlasController::me()` →
   `MathSnapshotService::for($user)` returns the field→track→skill tree, and the
   doc comment states the payload is identical to the parent PWA's. A teacher
   "view student X" is the same snapshot resolved for a roster member (gated:
   teacher must share a house with X).

> The parent React app (`c:\projects\ags_parent`) and the EOL Angular teacher app
> (`c:\allgifted\mathfe`) are reference-only. The teacher dashboard should be
> built as **Filament resources** on this backend (Decision/§8d), consuming the
> same `MathSnapshotService` — not a dependency on either app.

---

## 8. Migration sequence (each step independently shippable)

**(a) Org layer behind a default `b2c` org — zero behaviour change.**
Create `organizations`, seed `b2c` org id 1, add nullable `houses.org_id`, set
the existing global house to `org_id = 1`. Backfill: every current user is
implicitly b2c (they already are). Nothing reads `org_id` yet. Pure additive
schema; OTP/SSO/cascade untouched. *Ship + verify no regression.*

**(b) Introduce `EntitlementService` with the b2c source only — refactor.**
Wrap the *existing* `access_type === 'premium'` + `house_id=1` checks behind
`EntitlementService::for()`. `OTPController::loginResponse`, the SSO
`is_subscriber`, and `FeatureAccessService` all call the one resolver. Output is
byte-for-byte the same as today (b2c source returns what those checks return
now). This is the seam that later sources plug into.

**(c) Real centre tenancy + seat billing.**
Revive `createMathEnrollment()` as a proper `EnrolmentService` (it's the dead
writer that already knows the `house_role_user` shape). Add the centre-seat
EntitlementService source. Wire seat-based Stripe (`quantity`) + webhook fan-out
to `house_role_user`. First real non-b2c orgs onboard here.

**(d) Filament teacher dashboards.**
Add `OrganizationResource`, `ClassResource` (`House`), roster management, and the
class-centric read-models from §7 as Filament pages/widgets. Gate via OTP login +
`memberships`/teacher `role_id` (extend `canAccessPanel`, currently `[1,9,10,11]`,
with a teacher path or a separate panel). Consumes `MathSnapshotService`.

**(e) School PO + SSO.**
Manual `licences` records (source #3), then Google for Education / MOE SLS as
additional `SsoController` providers mapping email-domain/SLS-code → org.

Dependency order: **a → b** are prerequisites for everything; **c, d, e** are
largely independent after b (c before d is convenient so dashboards have real
tenants to show).

---

## 9. Open decisions (with recommendations)

**D1 — Token-bound org vs explicit switch-org.**
*Fork:* bake the org into the Sanctum token ability at login (`org:{id}`), or
keep one token and pass org per-request (`X-Org-Id` + `switch-org`).
**Recommend: token ability for single-org users (the vast majority), explicit
switch-org for the multi-org minority.** Cheap to scope, and most teachers belong
to one centre/school. The org picker reuses the existing
`requires_profile_completion`-style branching in `verifyOtp`.

**D2 — Keep progress user-global vs per-house.**
*Fork:* leave the cascade user-global (today's behaviour) or shard pivots by
house.
**Recommend: stay user-global for K-6.** A child's fraction mastery is the same
fact in any class; per-house progress would duplicate the cascade, break the
"one learner one Atlas" model the parent app depends on, and double write cost.
Teacher scoping is a read-time roster intersection (§7) — it needs nothing more.
(Revisit only if a school demands that progress earned in school be walled off
from the home/B2C identity — a policy, not a technical, requirement.)

**D3 — Revive `house` vs greenfield `cohorts`.**
*Fork:* reuse the dormant `house`/`house_role_user`/`house_track` tables, or
build fresh `cohorts` tables.
**Recommend: revive `house`.** It already models roster + roles + seats
(`places_alloted`) + join-code (`mastercode`) + per-seat entitlement
(`expiry_date`/`payment_status`/`plan_id`) + per-class curriculum
(`house_track`), and `House.php`/`User.php` already carry the relations
(`enrolledStudents`, `teachers`, `tracks`, `current_track`, `houseRoles`,
`activeHouseRoles`). Greenfield would re-implement all of it. The only real cost
is naming (D4) and one column add (`org_id`).

**D4 — Label "house" → "Class" in B2B UI.**
*Fork:* keep the word "house" externally, or relabel.
**Recommend: relabel to "Class" in all B2B-facing copy/UI; keep `house*` as the
internal table/model names.** Mirrors the existing AGS-Math-Tutor brand rule
(student-facing copy vs generic internal class names). "House" is meaningless to
a Singapore tuition centre / MOE school; "Class" is the domain term. Zero schema
impact.

---

## Executive summary

**Target model (5 bullets):**

1. **New `organizations` tier (school|centre|partner|b2c) above the existing
   `house`**, where a `house` = a *class*; add one `houses.org_id` column and a
   small `memberships` table for people not tied to a single class.
2. **Single DB, row-level `org_id` global scope** (not db-per-tenant): content
   (fields/tracks/skills/questions) stays GLOBAL; rosters/curriculum/entitlements
   are TENANT-SCOPED; progress pivots stay USER-GLOBAL and are read-scoped by
   roster at query time.
3. **One `EntitlementService`** with four sources — b2c (existing Stripe
   `access_type`), centre-seat (`house_role_user.places_alloted/expiry_date/
   payment_status`), school-PO licence, partner (generalised SIMBA) — and every
   access check routes through it.
4. **Auth reuses OTP** (teachers/owners; org-bound token), **SSO (Google for
   Education / MOE SLS) added later** as extra `SsoController` providers; billing
   is seat-based Stripe (`quantity`) for centres, manual licence for school POs,
   partner entitlement for telco — **webhook signature verification preserved**.
5. **Teacher analytics are class-centric read-models** (KPIs, curriculum
   pass-rates, student×track mastery matrix, attention list) built as Filament
   resources over the user-global cascade + `MathSnapshotService`, with no new
   write path and no re-sharding of the maxile cascade.

**Open decisions:** D1 token-bound-org (recommend token ability, switch-org for
multi-org) · D2 progress stays user-global (recommend yes for K-6) · D3 revive
`house` (recommend yes) vs greenfield `cohorts` · D4 relabel "house"→"Class" in
B2B UI, keep `house*` internally (recommend yes).

**Doc path:** `C:\allgifted\mathapi11v2\docs\sprints\b2b-tenancy-model-2026-06-06.md`
