# WP7 Report — The remaining resources, and closing the feature honestly

**Status:** DONE. POS tills wired end to end; cost centres deferred with a written reason; the three
loose ends closed. Zero NEW test failures.

**Commits (all on `hazemdev2`, NOT pushed, NOT merged):**
- BE `/home/moonui2/moon-erp-be` → **`9d9b0ef38`** (16 files, on top of WP6 `08708c359`)
- FE `/home/moonui2/public_html/moon-erp` → **`b888dda25`** (8 files, on top of WP6 `be548e5bc`)
  and **`55f4ecf9b`** (the silent-no-op fix caught in review — §5, read it)
- **NOT deployed to `/app`** — the orchestrator deploys.

**Arrival check:** both repos ON `hazemdev2`, clean trees, before start and before each commit.

---

## 0. ⚠️ THE BRIEF'S PREMISE WAS WRONG, AND THE CORRECTION REMOVED THE MIGRATION

> «**POS terminals** — `branch_id` exists, **no user link**» · «**Migration:** likely»

**Both till tables carry a NOT NULL `warehouse_id` — the exact axis WP3 already wired.**

| Evidence | Where |
|---|---|
| `$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();` | `Modules/POS/database/migrations/2026_02_28_600003_create_pos_terminals_table.php:18` |
| the same, on sessions | `…/2026_02_28_600004_create_pos_sessions_table.php:17` |
| Both NOT NULL (no `->nullable()`), FK to `warehouses` | same lines |

The brief is right that there is no *user* link. But it treats "no user link" as "no scoping axis",
and that does not follow — the warehouse axis is a scoping axis, and it is the one this feature spent
six work packages building. **Consequence: no migration was written and none was needed** (`git show
--stat 9d9b0ef38` shows zero migration files). `Migration: likely` resolves to **none**, and no new
pivot table was created.

This is the **second** WP in this feature to find a stale premise in its own brief (WP6 found the
`petty_cash_transactions.created_by` claim). Both times the correction *reduced* the work.

Everything else in the brief stood. Nothing else contradicted the code.

---

## 1. THE DESIGN DECISION: shared pivot, separate switch

This is the one thing to carry forward, and it is a decision about **blast radius**, not about tables.

**Reuse the pivot — `warehouse_user`.** A till *is* a point on a warehouse's stock. Its
`warehouse_id` is NOT NULL, so "which tills may I see" is already answered by the rows that answer
"which warehouses may I see". A `pos_terminal_user` pivot would have been a **second assignment list
that somebody has to keep in step with the first by hand**, for no extra expressiveness.

**Do NOT reuse the switch.** Registering POS under `inventory.warehouse_data_scope` was the obvious
shortcut and it is wrong. Decision 9 of this feature says turning restriction on is an explicit,
per-company, **per-resource** act. Sharing the key would mean:

> an administrator scopes his *stock keepers* → every unassigned **cashier** in the company can no
> longer open a shift → the tills stop, company-wide, because of a setting whose name says
> *"warehouse"*.

So POS got **`pos.terminal_data_scope`**. Same rows, own switch. Pinned by a test asserting the two
modes move independently **in both directions** (`the warehouse mode does NOT scope POS, and the POS
mode does NOT scope warehouses`), because this is precisely the kind of thing a later refactor
"tidies" into one key.

A second test pins the pivot reuse itself, for the mirror-image reason: somebody will one day want to
"clean this up" into a `pos_terminal_user` table.

---

## 2. What landed on the backend

**New: `Modules/POS/app/Support/ScopesPosTerminals.php`** — the shared layer, a trait (used by
`POSBaseController`, so every POS controller inherits it, and available to `POSReportService` if the
reports are ever scoped). Five helpers, mirroring WP3's four plus one:

| Helper | For |
|---|---|
| `scopePosSession($q)` | `pos_sessions` — has `warehouse_id` **and** `user_id`, so `own_records` means "shifts I held" |
| `scopePosTerminalCatalogue($q)` | `pos_terminals` — **no owner column**, so `owner => null` ⇒ `own_records` falls back to `assigned` (WP1 §5), never unrestricted |
| `posTerminalScopeIds()` | list-shaped callers. `null` = unrestricted; `[]` = restricted with no assignment ⇒ zero tills |
| `posTerminalVisibleOr404($id)` | a terminal id in a URL **or a request body** |
| `posSessionVisibleOr404($id)` | **route model binding** — Laravel resolves the row before any query scope can reach it, so this is a second scoped probe for the same id |

Registry entry in `ResourceScope::RESOURCES` under `pos_terminal`: pivot `warehouse_user`, column
`warehouse_id`, **`owner => 'user_id'`**. No controller calls `ResourceScope` ad-hoc — WP3's contract.

### Endpoints wired

- **`POSTerminalController::index`** — scoped **before** the request `branch_id`/`is_active` filters,
  so those stay what they always were: convenience narrowing under a real ceiling. Omitting
  `branch_id` can no longer widen the list.
- **`POSTerminalController` `show`/`update`/`destroy`** — the check is placed **inside the existing
  private `authorizeCompany()`**, the single door all three already go through, so it cannot be
  forgotten on one of them. The cross-company **403 is untouched and still runs first**; the scope
  answers **404** (a 403 confirms the record exists — decision #4).
- **`POSSessionController::index`** — the ceiling. See §3.
- **`POSSessionController` `show`/`close`** — same single-door treatment via `authorizeCompany()`.
- **`POSSessionController::open`** — the terminal fetch is **scope-checked in the find**, so an
  out-of-scope `terminal_id` falls into the *existing* `"Terminal not found."` 404 path rather than
  needing a new branch. Before this, `open` validated **company + `is_active` only**: any cashier
  could start a shift on any till in the company.

### What was deliberately NOT scoped (all recorded in the invariant with written reasons)

1. **`sessions/active`** — already `user_id = me`, strictly narrower than anything the scope could
   add. Gating it would risk locking a cashier out of the shift he is *standing in* when an admin
   flips the mode mid-shift.
2. **Creation endpoints** (`terminals.store`, `sales.store`, `refunds.store`) — the WP3 §8 / **D1**
   write-path question, still the owner's.
3. **Coupons, held orders, products, receipt-barcode lookup, settings** — each has a real reason in
   the file. Held orders are worth naming: the table has **no `terminal_id` and no `warehouse_id`**,
   and shared pickup by any cashier at the counter is the *point* of parking a basket.
4. **The POS reports — see §7, this is a live exposure, not a clean exemption.**

---

## 3. THE LEAK THIS PACKAGE EXISTS FOR

`POSSessionController::index`'s only user predicate was:

```php
if ($request->filled('user_id')) {
    $query->where('user_id', $request->input('user_id'));
}
```

An **optional convenience filter**, never a ceiling. And the seeded **`cashier` role holds
`pos.sessions.view`** (`RolePermissionSeeder.php:2029`). So every cashier could list **every
colleague's shift** — opening float, counted drawer, expected balance, variance — by the trivial
expedient of *omitting the parameter*.

Pinned by a named test that asks for another user's shifts **by id** and gets `[]`, then omits the
parameter and gets only his own, then confirms the filter still works as a filter underneath.

### ⚠️ Honest reading of acceptance criterion 2

> «POS session listing is enforced server-side rather than by a client-supplied `user_id`.»

Enforcement **is** now server-side. But it only **binds** once a company leaves mode `all` — because
the non-negotiable says mode `all` must behave *exactly* as today. **In default mode, every cashier
still lists every shift.** The two requirements are in genuine tension and this resolves it the same
way WP3 and WP6 did: the mechanism exists, is proven, and is one settings write away. The CHANGELOG
says this in both languages so nobody reads the release note as "fixed for everyone today".

---

## 4. Acceptance criteria → evidence

**New: `Modules/POS/tests/Feature/PosTerminalScopeWiringTest.php` — 11 tests / 61 assertions**
(every top-level helper prefixed `posScopeWiring…`).

| # | Criterion | Test |
|---|---|---|
| 1 | **mode `all` = today, proved** | `mode all (no setting row, no assignment) leaves every POS screen exactly as it was` — **no setting row at all and no pivot row** (the state of every existing install): both tills listed, all four shifts listed, by-id 200 on a till and a shift he has no part in, and a shift still **opens** on an unassigned till. Had any default path gained a clause, the empty assignment would have emptied all of it |
| 2 | `assigned` narrows correctly | `assigned: a cashier of one warehouse sees only that warehouse's tills and shifts` — incl. his **colleague's** shift in his own warehouse (it is the warehouse axis, not the user axis) |
| 3 | empty assignment ⇒ zero, never all | `assigned + NO assignment = zero tills and zero shifts, never all of them` |
| 4 | out-of-scope by id ⇒ 404 | `an out-of-scope till or shift answers 404 by id, and the in-scope twin still answers 200` — show, update, destroy, session show |
| 4b | the money-touching one | `a shift cannot be OPENED on an out-of-scope till` — and his own till still opens |
| 5 | `own_records` is the user axis | `own_records: a cashier sees only the shifts he held himself, and tills fall back to assigned` — including **his own shift in a warehouse he is NOT assigned to**, which is what proves it is not a relabelled `assigned` |
| 6 | the request filter cannot widen | `the request user_id filter narrows under the ceiling and can never widen past it` |
| 7 | `active` untouched | `sessions/active is unaffected — it was already self-scoped by user_id` |
| 8 | system context exempt | `a userless system context is exempt — a restrictive mode does not empty its reads` |
| 9 | **the two switches are independent** | `the warehouse mode does NOT scope POS, and the POS mode does NOT scope warehouses` |
| 10 | the pivot reuse is deliberate | `the POS resource reuses the warehouse_user pivot rather than a pivot of its own` |

### The invariant — `Modules/POS/tests/Feature/PosScopeInvariantTest.php` (4 tests)

`POS_SCOPE_INVARIANT_WIRED = true`, same expected-red / cannot-rot machinery as WP2 and WP6.

**Enumeration axis: back to WP2's PREFIX, and the reasoning is written into the file.** WP6 had to
abandon prefix enumeration because the cash surface spans three modules (~700 routes). POS is the
opposite case: the till tables are read by the POS module and nowhere else, and `api.pos.*` is **33
routes** — small enough that *every single one* is named. Prefix enumeration is here both sufficient
and **stricter**: it enumerates routes that do not touch a till at all and forces each to be
justified, rather than letting a source-string heuristic quietly drop them.

**33 routes = 8 probed + 25 exempted with written reasons** (the test rejects a reason under 60
characters, so "n/a" cannot pass).

**Plus a guard for prefix enumeration's one blind spot.** `sales_invoices` already carries
`pos_terminal_id` and `pos_session_id`, so a Sales or report controller listing terminals or shifts
is a realistic future change — and it would sit *outside* `api.pos.*` where the coverage test cannot
see it. `no route outside api.pos reads a till table without this invariant knowing about it` fails
the moment a non-POS controller imports `POSTerminal`/`POSSession` or queries `pos_terminals`/
`pos_sessions`. Matching is on model/table names only, so merely *carrying* a `pos_session_id` column
(which POS sales and refunds do all day) is correctly not a match.

**The probes were proven to measure something, not assumed to.** I temporarily removed the
`scopePosSession($query)` line and re-ran:

```
✗ api.pos.sessions.index
    LEAK: the out-of-scope marker 'SESSION-LEAK' is present in the response body.
    → scope the query with the ScopesPosTerminals trait …
Tests: 1 failed, 3 passed
```

then restored it and re-verified green. (The wiring landed before the tests were written, so without
this the suite would have been green-by-construction and would have proven nothing.)

---

## 5. 🔴 A SILENT NO-OP, CAUGHT IN REVIEW — the feature's own failure mode, inside its last package

Worth reading even if nothing else here is.

`SettingService.ALL_MODULES` — the fan-out the settings screen uses when no module is named — listed
`accounting, sales, purchases, inventory, hrm`. **Not `pos`.**

The settings data-scope panel resolves its setting by **exact key** from `allSettings()`, which that
fan-out feeds, and the entire group is wrapped in `@if (dataScopeSetting(resource); as setting)`. So:

1. the POS till data-scope group would have rendered **nothing**, silently; and
2. because the key is *deliberately* excluded from the POS tab's generic rows (it needs WP5's lockout
   guard rail, not a bare enum dropdown), **the mode would have had no write path in the UI at all** —
   enforced server-side, invisible and unreachable in the app.

`tsc --noEmit` was clean and `ng build` was green **both before and after** the fix. Neither can see
an `@if` that is always false.

Fixed in **`55f4ecf9b`**, with the reasoning written into the constant, and **verified against the
live dev API** rather than by inspection: `GET /core/settings?module=pos` returns 49 rows including
`pos.terminal_data_scope` (module `pos`, `is_implemented` true, `current_value` `all`).

This is exactly the class of defect the whole feature exists to eliminate — *a control that looks
like it does something and does nothing* — reproduced inside the feature's own close-out. It was
found by a review pass asking "did you verify the panel actually renders?", not by any test or build.

---

## 6. Part 2 — the three loose ends

### 6.1 The legacy columns: **KEPT, and now unambiguous**

`warehouses.manager_id` and `petty_cash.custodian_id` are **kept** and marked **DISPLAY-ONLY** on:
- the model relations (`Warehouse::manager()`, `PettyCash::custodian()` — with the full argument),
- `WarehouseResource` and `PettyCashResource`, so a client reading the field sees the statement too.

Every one now says, in substance: **"restricts nothing; enforcement lives in `warehouse_user` /
`petty_cash_user`."**

**Why kept and not dropped — one decisive fact.** WP5 shipped a legacy-assignment **review panel** in
the settings screen that is derived *entirely* from these two columns (it lists "manager X on
warehouse Y — assign him properly?"). **Dropping the columns deletes the data that panel reads and
breaks a screen this same feature shipped.** Their other uses — the WebStore admin request, the API
resources, the warehouse and cash-box forms — are all display, and "who do I ring about this
warehouse" is a genuinely useful field to keep.

The brief's real requirement was *"do not leave them ambiguous"*, and that is what is done. An
unmarked dead column that once looked like security is what started this feature; **there is no
longer an unmarked one.** Noted in code: if either is ever dropped, the WP5 review panel goes with it.

### 6.2 Invariant coverage across every wired module — verified, all green as hard gates

| File | Module | Result |
|---|---|---|
| `Modules/Inventory/tests/Feature/InventoryScopeInvariantTest.php` | Inventory (WP2/WP3) | **3 passed** |
| `Modules/Accounting/tests/Feature/CashBoxScopeInvariantTest.php` | Accounting + LIS + Clinic (WP6) | **3 passed** |
| `Modules/POS/tests/Feature/PosScopeInvariantTest.php` | POS (WP7) | **4 passed** |

Every module this feature touched is covered, every exemption carries a written reason, and each file
carries its own `…_WIRED = true` anti-rot guard (all probes passing while the flag is false is itself
a failure). Helper prefixes are distinct per file (`inventoryScopeInvariant…` /
`cashBoxScopeInvariant…` / `posScopeInvariant…`) — no redeclare risk in Pest's single process.

### 6.3 The app-wide client-side row-filtering sweep

Swept `src/app` exhaustively (features, core services, shared, all `*.selectors.ts`, and templates).
**Two survivors, both outside this feature's three resources. Listed, not fixed — as the brief asked.**

**🔴 1. HR employee profile downloads the whole company's payroll.**
`src/app/features/hr/employee-profile/hr-employee-profile.component.ts:289`
```ts
const empItems = payroll.items.filter((item) => item.employee_id === id);
```
The list comes from `payrollService.listAll()` (line 267) — `GET /hr/payrolls` with **no
`employee_id` parameter** — i.e. *every payroll run in the company*, each carrying its per-employee
salary `items[]`. The browser then keeps one employee's lines. The **three sibling requests in the
same `forkJoin`** all scope server-side (`attendance?employee_id=`, `leave-requests?employee_id=`,
`loans?employee_id=`), so this reads as an oversight rather than a design choice.
**This is a real leak and the highest-sensitivity payload in the app. It needs an owner ticket.**

**2. Bank accounts have no server-side scope anywhere.** Eight screens
(`bank-reconciliations:94`, `checks-received:70`, `checks-issued:88`, `expenses:274`,
`payment-vouchers:344`, `receipt-vouchers:383`, `revenues:275`, `transfers:123`) apply
`.filter(ba => this.branchContext.isBranchResourceAllowed(ba.branches))` over an unscoped
`BankAccountService.listAll()`. The filter **cannot be a security boundary**: ~10 *other* screens bind
the identical unfiltered list with no rule at all, so removing the eight exposes nothing new. But the
asymmetry is the finding — warehouses got WP3, cash boxes WP6, POS tills WP7; **bank accounts got
nothing.** Whether they should be branch-restricted is a policy question the owner has never been
asked.

**Confirmed benign** (documented WP4/WP6 picker *presentation* over lists the server already scopes,
now verified against the BE): `warehouse.service.ts:73`, `petty-cash.service.ts:58`, the three
`filterBranchesForPicker` branch pickers, `branch-context.service.ts:86/92` (a default-value lookup,
narrows no displayed list), and the two admin catalogues that intentionally read the full list to
assign it. **Excluded by rule:** `is_active`/status/type/search/date/currency filters, sort/map/dedupe,
display-only `.find()` label lookups, user-chosen dropdown filters, and filters over the already-
delivered profile object. **NgRx:** zero `createSelector` in `core/store/` — no scoping layer exists
there at all. **The worst historical instance is already gone:** the shift-close dialog used to
`forkJoin` every posted payment in the company and filter by string date comparison; WP-POS replaced
it with one server-computed `data.summary`.

---

## 7. Deferrals raised by this WP

### D4 — cost centres: **not worth it, and here is why**

`cost_centers` has **no user link and no branch column**. The create migration
(`2026_02_10_100006`) is `company_id`, code, name/name_ar, parent, level, status, has_children,
description; the only later addition is `type`. It is the one resource in the analysis with **no
scoping axis whatsoever** — unlike POS tills, which turned out to have one all along.

Building one means a new `cost_center_user` pivot **plus** a new mode key, and then somebody must
populate it. Against that: a cost centre is an accounting **classification**, not a custody object.
It is picked on journal lines, allocation rules and expense documents, and a chart of classifications
is company structure in the same way the chart of accounts is — which WP6 exempted for exactly this
reason ("scoping the chart of accounts by cash-box assignment would hide GL structure that has
nothing to do with custody"). **Nobody has asked to restrict who can see one.**

**Not built.** If an owner ever asks for per-user cost-centre visibility, the engine takes it in a
day — the only missing piece is the pivot.

### D5 — ⚠️ the POS reports are a **live** exposure, not a clean exemption

`api.pos.reports.cashier-performance` names every cashier with their transaction count, total sales,
average sale, discounts and refunds. **The seeded `cashier` role holds `pos.reports.view`**
(`RolePermissionSeeder.php:2038`). So it is the *same leak class* as the `sessions.index` hole this WP
just closed, and **it is still open.**

I did not scope it, and the reason is not "out of scope" — it is that **the pattern does not apply
mechanically**, which the brief explicitly said was worth reporting rather than forcing:

- the reports read `sales_invoices` **and `sales_returns`**;
- **`sales_returns` has no `warehouse_id` column at all** (checked: only `created_by`);
- so the refunds leg could only be scoped by adding a join through `pos_sessions` — a **new query
  shape**, not an application of this engine;
- and scoping the takings leg but not the refunds leg would make the two disagree. **On a money
  report that is worse than the honest exposure.**

`daily-sales` and `product-sales` share the axis problem and produce no per-user attribution at all.

**Interim remedy is a permission one: revoke `pos.reports.view` from the `cashier` role.** The proper
fix belongs to a package that can take the *sales* reporting scope axis as a whole. It is written into
the invariant's exemption text (the longest reason in the file) so it stays reviewable.

### D6 — the two surviving client-side filters (§6.3), for the owner

---

## 8. The frontend

- **New `PosTerminalScopeService`** (`core/services/pos-terminal-scope.service.ts`) — the third
  sibling of WP4's `WarehouseScopeService` and WP6's `CashBoxScopeService`, and separate for the same
  documented reason: each is `providedIn: 'root'` with a one-shot constructor fetch and a private
  latch, so none can be injected twice with two setting keys. Fails open to `all` on any read error.
  `pos.terminal_data_scope` was added to `SettingController::PUBLIC_READ_KEYS` so a **cashier** — who
  holds no `core.settings.view` — can read it (the WP4 → `85f57af12` precedent, verbatim).
- **Session-open dialog** — the brief's named UX complaint, fixed:
  - the **sole terminal is auto-selected and rendered as static text** instead of a one-option select.
    Deliberately **not** gated on `isScoped()`, unlike WP4/WP6's pickers: those sit on documents whose
    option set can change within a session, so collapsing them in mode `all` would hide a real choice.
    This list is the shift-open gate, fetched once — one option means one till, in every mode, and the
    cashier faces it every single day. A note explains *why* there is only one when a restriction is
    the cause.
  - **the lockout is said out loud.** Under a restrictive mode an unassigned cashier gets an **empty**
    list by design, and an empty dropdown above a dead *Open* button is the most confusing thing this
    screen could do. It now distinguishes *"no terminals exist"* from *"none assigned to you — ask an
    administrator to assign you to the warehouse your terminal belongs to."* A **failed** fetch
    deliberately shows neither: "we don't know" must not read as "you are unassigned".
- **Settings** — the POS tab gains the **shared, guard-railed** data-scope group rather than a bare
  enum row, because this is the resource where a careless flip is most likely to lock people out. Its
  coverage count reads `warehouse_ids` (a till is scoped by its warehouse). `getPosGroupSettings()`
  now excludes managed keys the way `getModuleSettings()` already did — without that, the key would
  have had a **second, unguarded write path**. The group template needed no changes: it is fully
  definition-driven, so a fourth resource costs one line plus a `dataScopeKeys` entry.
- **i18n** — 3 new `POS.*` keys added **additively to BOTH** `ar.json` and `en.json` by a script that
  refuses to write unless the re-encode re-parses; both re-verified with `JSON.parse`. **No git
  operation ever touched the i18n files.**
  *Full disclosure:* the first write reformatted both files (4-space vs the original 2-space) — caught
  immediately and rewritten at 2-space, leaving a **3-line diff per file** plus one line where
  `—`/`›` escapes normalised to the identical literal `—`/`›` characters. Same parsed value;
  the rest of the file already stores non-ASCII literally.
- `npx tsc --noEmit` **zero errors**; `npx ng build --base-href /app/` **green** (only the pre-existing
  CommonJS warnings). **NOT deployed to `/app`.**

---

## 9. Test runs

All via `cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest …`

| Run | Result |
|---|---|
| `PosTerminalScopeWiringTest.php` | **11 passed (61 assertions)** |
| `PosScopeInvariantTest.php` | **4 passed** |
| `PosScopeInvariantTest.php` with the scope line temporarily removed | **1 failed** — the probe is real (output in §4) |
| **`pest Modules/POS`** (full module suite) | **273 passed, 0 failed** (1458 assertions) |
| `InventoryScopeInvariantTest.php` (WP2/WP3 regression) | **3 passed** — still a hard gate |
| `CashBoxScopeInvariantTest.php` (WP6 regression) | **3 passed** — still a hard gate |
| `RequireBatchOnReceiptTest.php` + `POSComingSoonSettingApiTest.php` (the two locked-count files) | **29 passed (320 assertions)** with the other two invariants |

`./vendor/bin/pint` on touched files only → `{"result":"pass"}`. Every edited file
`chown moonui2:moonui2`. `bash local-deploy.sh` run (**"no new migrations"** — as §0 predicted).

### The locked-catalogue count: **unchanged at 39 in both files**

The new key ships **already unlocked**, unlike WP1's two — and that is not an inconsistency. WP1's
keys were locked because their wiring landed in *later* packages (WP3, WP6), so a real window existed
in which an admin could switch a company into a mode nothing enforced. **Here the wiring and the
definition land in the same commit**, so no such window exists and there is nothing for the lock to
protect. Both count files carry the arithmetic and the reasoning; `POS_COMING_SOON_TAB_COUNT` also
stays 39, with a new note that a locked `pos_*` key would move **both** numbers while a locked
non-`pos_*` key moves only one — which is why they remain two constants.

### `is_implemented` verified writable on dev, then restored

Re-seeded `SettingDefinitionSeeder` on `moonui2_dev_be`. Verified: definition present,
`is_implemented=true`, group `pos_operations` · `SettingsService::set(…, 'assigned')` **write
accepted** · `ResourceScope::mode('pos_terminal')` answered `assigned` · **restored to `'all'`** and
re-verified. **Dev is left in the default state.** Locked count on dev: **39**.

---

## 10. Files touched

**BE (16)** — `Modules/POS/app/Support/ScopesPosTerminals.php` **(new)** ·
`Modules/POS/app/Http/Controllers/{POSBaseController,POSSessionController,POSTerminalController}.php` ·
`Modules/POS/tests/Feature/{PosScopeInvariantTest,PosTerminalScopeWiringTest}.php` **(new)** ·
`Modules/Core/app/Support/ResourceScope.php` (the registry entry) ·
`Modules/Core/app/Http/Controllers/SettingController.php` (`PUBLIC_READ_KEYS`) ·
`Modules/Core/database/seeders/SettingDefinitionSeeder.php` (the new definition) ·
`Modules/Inventory/app/Models/Warehouse.php` + `…/Http/Resources/WarehouseResource.php` (legacy marking) ·
`Modules/Accounting/app/Models/PettyCash.php` + `…/Http/Resources/PettyCashResource.php` (legacy marking) ·
`Modules/Inventory/tests/Feature/RequireBatchOnReceiptTest.php` + `Modules/POS/tests/Feature/POSComingSoonSettingApiTest.php` (count notes) ·
`docs/moonstack/CHANGELOG.md` (bilingual `[Unreleased]` bullet).

**FE (9)** — `core/services/pos-terminal-scope.service.ts` **(new)** · `core/services/setting.service.ts` (§5) ·
`features/settings/settings.component.{ts,html}` ·
`features/pos/components/session-open-dialog/session-open-dialog.component.{ts,html,scss}` ·
`assets/i18n/{ar,en}.json`.

---

## 11. Concerns / hand-offs

1. **⚠️ (Operational — tell the owner) ASSIGN FIRST, FLIP SECOND, and it applies to BOTH restrictive
   modes.** Under `assigned` *and* under `own_records`, a cashier with no `warehouse_user` row sees
   zero terminals and **cannot open a shift** — `own_records` is not an escape, because the terminals
   catalogue has no owner column and falls back to `assigned`. Cashiers are not usually who an admin
   thinks of when assigning *warehouses*, so this is more likely to bite here than it was for
   warehouses or cash boxes. Recovery is one settings write back to `all`. Said in the setting's own
   bilingual description, in the CHANGELOG, in the code, and now in the shift-open dialog itself.
2. **(→ owner) D5, the POS reports.** The `cashier` role can still see every cashier's takings. This
   is the only *known, live* leak of this class left in the wired surface. Interim remedy: revoke
   `pos.reports.view` from `cashier`.
3. **(→ owner) D6 item 1, the HR payroll download.** Not this feature's resource, but it is the most
   sensitive payload the sweep found and it is a genuine server-side gap, not a picker rule.
4. **(→ owner) D6 item 2, bank accounts.** The only resource in the app with client-side branch rules
   and *no* server-side scope at all. A policy question, not a bug.
5. **D1 and D2 remain open and should be answered together** (may a keeper create into a warehouse he
   is not assigned to; may a cashier's money land in a box he cannot see). WP7 adds a third instance
   of the same question — `terminals.store` is exempt on the identical reasoning — so it is now a
   pattern across all three resources rather than a quirk of one.
6. **Enumeration-axis note.** `PosScopeInvariantTest` enumerates by `api.pos.*` prefix. The foreign-
   reader guard (§4) covers a till being read from another module, but it matches on model/table
   names; a controller reaching tills through a *helper class* that itself queries them would not be
   caught. The rule lives in one function (`posScopeInvariantForeignReaders`) if it ever needs widening.
7. **FE mode caching.** `PosTerminalScopeService` fetches the mode once per session, same as its two
   siblings — a mode flip mid-session shows on next reload, consistent with the existing
   "permissions need re-login" behaviour.
8. **Pre-existing reds are unchanged and belong to others:** Inventory's 4 known baseline failures and
   `LabPaymentApiTest > can get daily payment summary` (**D3**). Nothing in this WP touches either.
