# 19 — Branch isolation

**Branch is an isolation boundary, not a filter.** When a branch is selected,
no screen anywhere in the system may contain a record from another branch — not
in a table, not in a `<select>`, not in a count badge, not in a typeahead, not
in a printed PDF. A user switching branches should feel like they switched
installations.

This is enforced at the model layer so no screen can forget it.

---

## The three moving parts

| Piece | File | Job |
|---|---|---|
| `BranchContext` | `app/Domain/Shared/Context/BranchContext.php` | Which branch is this request in? Owns the session rules. |
| `BranchScopeState` | `app/Domain/Shared/Context/BranchScopeState.php` | Should Eloquent enforce it? Owns the on/off switch and suppression. |
| `BranchScope` | `app/Domain/Shared/Scopes/BranchScope.php` | The global scope itself. |

`ResolveBranchContext` middleware resolves the branch and then **activates**
enforcement. Enforcement is deliberately off in four places, and each is a real
case:

- **Console and queues** — no session, no branch. A cron that silently billed
  one branch would be far worse than an unfiltered read.
- **Guests** — the public website and the login screen have no branch.
- **Member portal accounts** — a parent must see their own child's invoices
  and attendance wherever that child trains. Detected by role slug
  (`config('branch_scope.member_roles')`), not by permission, because a
  member's permissions overlap a staff member's.
- **Routes in `config('branch_scope.unscoped_routes')`** — the portal, the
  public site, the cross-academy super-admin panel.

Note `config('branch_scope.unscoped_routes')` is **not**
`config('branch_lock.unlocked')`. They answer different questions: the lock list
says which pages a user in *all-branches* mode may open; the scope list says
which pages have no branch to enforce. Reports are unlocked but scoped — with a
branch selected, a report shows that branch only.

---

## Classifying a model

Every model with branch-owned data falls into one of four buckets. Pick the
bucket **before** writing the migration.

### STRICT — `use BelongsToBranch;`

The default, and where nearly everything belongs. Filtered
`WHERE branch_id = :active`.

Two groups sit here:

- **Operational records** owned by exactly one branch — a player, a group, an
  enrolment, a session, an attendance record, a facility, an invoice, a payment,
  a cash session, a stock movement.
- **The catalogue** — programmes, base prices, pricing rules, promotions,
  products, product categories, kits, warehouses, receipt templates, wallets.
  A branch runs its own programmes at its own prices and sells its own stock.
  A programme offered at one branch appearing in another branch's dropdown is
  the same bug as a group doing it.

**A null branch here is a bug**, not a value — the row would be invisible from
every branch at once. Backfill it in the migration and give it a fallback.

The catalogue was briefly modelled as shared, on the reasoning that a missing
base price is a hard failure that stops a sale. That was the wrong trade: it
bought safety by making every branch's price list visible everywhere. The right
fix is to make sure every branch has its own prices — which the backfill in
`2026_09_13_000002` does, and which
`tests/Feature/PricingSurvivesBranchScopeTest.php` keeps honest by pricing every
live enrolment inside its own branch on each run.

### SHARED — `use BelongsToBranch;` + `protected static bool $branchScopeAllowsShared = true;`

Filtered `WHERE (branch_id = :active OR branch_id IS NULL)`, where null means
*every branch uses this row*.

Reserved for the few things that genuinely belong to more than one branch at
once: **people** (employees, trainers, guardians) and **the academy calendar**
(holidays). A coach who works Monday at one pitch and Wednesday at another needs
one record visible from both, not two records that drift apart.

Even here, null is for real sharing only. Anyone who demonstrably belongs to a
single branch is pinned to it by the migration; only a person actually spanning
branches keeps the null.

Do not reach for this bucket to avoid a backfill. If a table is shared because
its rows have no branch yet, the fix is the backfill.

### CHILD — `use ScopedThroughBranch;` + `protected static string $branchScopeRelation = 'parent';`

Line items and pivots — invoice items, POS lines, payslip items, evaluation
scores, installments. They have no branch of their own and **must not grow
one**: a denormalised copy on a child row is a second source of truth that
drifts the first time the parent moves. The scope is `whereHas($relation)` with
no extra clause, because the parent already carries `BranchScope` and
`whereHas` applies the related model's global scopes.

The relation must be non-nullable — `whereHas` drops rows whose parent is
missing, which is correct for a line item and wrong for an optional link.

### GLOBAL — no trait

Genuinely academy-level: the website CMS, the member portal's own tables, roles,
permissions, the chart of accounts, audit logs, activities, evaluation criteria,
system settings, people.

**Events** are here too, with their registrations. An event is an academy-wide
occasion — a tournament, an open day — and is one of the few operational things
that is deliberately not per branch. `events.branch_id` still exists (dropping a
column is destructive, and a per-branch event is plausible later) but nothing
reads it.

The **member portal** is global in a different sense: it is scoped by
membership, not by branch, so enforcement is switched off for member accounts
entirely. A signed-in member sees their own branch's world because their own
records live there — not because a filter put them there.

---

## What the global scope does **not** cover

The scope catches every Eloquent read. Four things go around it, and every one
of them has bitten this codebase:

### 1. `exists:` validation rules

`'facility_id' => 'required|exists:facilities,id'` runs a **raw table query**.
It accepts any id in the academy and would still accept it if the model were
scoped. Combined with a Livewire `public` property — settable from the browser —
that is a cross-branch write, not just a read.

```php
// wrong
'facility_id' => ['required', 'exists:facilities,id'],

// right
'facility_id' => ['required', Rule::exists('facilities', 'id')
    ->where('branch_id', $this->getActiveBranchId())],
```

For a SHARED table, allow the null too:

```php
Rule::exists('trainers', 'id')->where(
    fn ($q) => $q->where('branch_id', $branchId)->orWhereNull('branch_id')
)
```

### 2. Raw query-builder statements

`DB::table(...)`, `DB::select(...)` and raw joins bypass Eloquent entirely.
Filter them by hand, and remember to filter **every joined table**, not just the
driving one.

### 3. A browser-settable branch override

`#[Url] public string $branch_id` is a branch picker the user can set from the
URL. It is not a filter — it is an authorisation bypass, because nothing checks
the value against what the user is entitled to. Read the branch from
`getActiveBranchId()` instead. If a screen genuinely needs a picker, feed it
`$this->selectableBranches()`, which returns one entry unless the user is
actually in all-branches mode.

### 4. Blade rendering a relation that is now null

A `belongsTo` from a scoped model to another scoped model can return null where
it used to return a row — a group whose trainer sits at another branch, say.
Render those null-safely (`$group->trainer?->name_ar ?? '—'`).

Where the reference is legitimate and the related row must always resolve,
drop the scope on that one relation:

```php
public function trainer(): BelongsTo
{
    // The group is already proven to be in my branch; showing its coach's name
    // is showing my own data, not another branch's.
    return $this->belongsTo(Trainer::class)->withoutGlobalScope(BranchScope::class);
}
```

The rule: **`belongsTo` may drop the scope, `hasMany` and `belongsToMany` never
may** — those produce lists, which is exactly what the boundary is for.

---

## Reading across branches on purpose

A few operations are cross-branch by definition: transferring a player to
another branch, the executive roll-up, a consolidated export. Every one of them
is a hole in the isolation the rest of the app relies on, so make it obvious in
the diff.

```php
// one query
Facility::withoutBranchScope()->where(...)->get();

// one specific other branch
TrainingGroup::forBranch($targetBranchId)->get();

// a whole block, across every model it touches
$rows = TrainingGroup::acrossBranches(fn () => $this->buildExecutiveRollup());
```

In a Livewire component the `UsesBranchScope` trait gives you
`$this->acrossBranches(fn () => ...)`.

Do **not** reach for these to make a test pass or a screen render. If a screen
comes back empty after scoping, the usual cause is a row whose `branch_id`
never got backfilled — fix the data, not the boundary.

---

## Adding a new branch-owned table

1. Migration first. `branch_id` nullable, `constrained('branches')->nullOnDelete()`
   — deleting a branch must never delete a player's history with it.
2. Backfill from the natural parent, then fall back to the academy's main
   branch (`is_main DESC, id ASC` — several tenants have `is_main` false on
   every branch).
3. Index `['academy_id', 'branch_id']`. Every scoped read now carries both.
4. Add the trait and put `'branch_id'` in `$fillable`.
5. Add the model to `tests/Feature/BranchIsolationTest.php`, in `STRICT` or
   `SHARED`. That test runs against a restored tenant and compares what Eloquent
   returns against what raw SQL says is in the branch.

---

## Checking a client after a deploy

There are ~30 client databases, one push deploys to all of them, and there is no
staging in between. Enforcement is in the code, so it is identical everywhere —
but the *data* is not, and the data decides whether the enforcement helps or
hurts.

```bash
php artisan branch:audit          # human-readable; exits non-zero on a problem
php artisan branch:audit --json   # for a deploy gate
```

It reports three things, all of which are silent in the UI:

- **A strictly-scoped row with no branch.** Not a leak — a disappearance. The
  record is in the database, counted by SQL, and on no screen in the product.
- **A child in a different branch from its parent.** Renders blank; for a group
  whose programme sits elsewhere it also means no base price, and no base price
  is a hard failure that stops the sale.
- **A programme with live enrolments and no active price**, which produces the
  same `لا يوجد سعر محدد` at the till as the case above and is worth telling
  apart from it.

`2026_09_13_000003` fixes the second case for programmes by giving each branch
its own copy — rather than picking a winner and orphaning the loser's groups —
and repointing that branch's groups, enrolments, prices and product bundles at
it. Two branches running the same programme end up with two rows that can then
diverge, which is the point: a branch is its own installation.

## The tests, and which one runs when

| Test | Needs a tenant? | Catches |
|---|---|---|
| `BranchValidationRulesTest` | no | a raw `exists:` rule, or a model with `branch_id` and no declared scope |
| `BranchIsolationTest` | yes | a model returning the wrong branch's rows |
| `BranchScopedScreensTest` | yes | another branch's records reaching a page |
| `DashboardQueriesRespectBranchTest` | yes | a total computed from the wrong branch |
| `PricingSurvivesBranchScopeTest` | yes | scoping having taken a price away |

Only the first runs on a plain `php artisan test`. The rest skip without a
restored Postgres copy of a client, so a green suite on a laptop is not evidence
about the branch boundary — restore one and run them:

```bash
DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter Branch
```
