# 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;`

An operational record owned by exactly one branch: a player, a group, a
facility, an invoice, a payment, an attendance record, a cash session. Filtered
`WHERE branch_id = :active`. **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.

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

Catalogue and configuration, where `branch_id IS NULL` means *every branch uses
this row*: products, base prices, pricing rules, promotions, receipt templates,
programmes, holidays, warehouses, employees, trainers. Filtered
`WHERE (branch_id = :active OR branch_id IS NULL)`.

Hard-filtering these would be a production outage, not a tightening: pinning the
academy-wide base price to one branch leaves every other branch with no active
price, and *no active base price is a hard failure that stops a sale*.

### 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, roles, permissions, the chart of
accounts, audit logs, activities, evaluation criteria, system settings, people.
Branch has no meaning here.

---

## 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('products', '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.
