# Module Entitlement Completion

**Branch:** `module-entitlement-completion` (off `origin/main` at `d6daf68`)
**Companion docs:** `MODULE_ENTITLEMENT_AUDIT.md` (read-only audit that
identified the gaps closed here), `RETAIL_AUDIT.md`, `ZF_AUDIT.md`.

Closes the entitlement asymmetry surfaced by the audit. The platform
now supports `retail`-only, `dine`-only, mixed, and `enterprise` (every
module) tenants end-to-end — at the backend route layer, the frontend
nav, and the route-level guard.

---

## 1 · Old behavior vs new behavior

| Concern | Before | After |
|---|---|---|
| Retail surface (`/pos/*`, `/catalog/*`, `/inventory/*`, suppliers, growth, …) | Reachable by every tenant. No `module:retail` middleware. | Wrapped in `Route::middleware('module:retail')`. Tenants without retail get HTTP 403 `MODULE_NOT_ENTITLED`. |
| ZF surface (`/zf/*`) | Reachable by every tenant. Not in `ALL_MODULES`. | `zf` added to `ALL_MODULES` (premium). `/zf/*` wrapped in `module:zf`. |
| No-subscription tenant | Got the full `openEverything()` payload — every module silently enabled. | Gets `emptyEntitlements()` — `modules: []`, `blocked: true`, `blocked_reason: 'no_subscription'`. Every module-gated route returns 403. |
| Demo tenant | Got `openEverything()`. | Still gets `openEverything()` — bypass preserved. |
| Suspended tenant on a starter plan, module not in plan | `module_access[*].reason = 'plan_limit'`. UI rendered "upgrade your plan." | `module_access[*].reason = 'suspended'`. UI renders "settle / unsuspend." Suspension always wins over plan_limit. |
| FE `modules` field | Aliased into `permissions` array; same namespace. | First-class `modules` field on session store; typed `hasModule()` selector; legacy alias preserved for back-compat. |
| FE module-gated routes | `<ProtectedRoute permission="retail">` only. Used "permission" string that happened to match module key. | `<ProtectedRoute permission="..." module="retail">`. New `<RequireModule>` typed counterpart to `<RequirePerm>`. |
| FE locked-module state | Generic `<Forbidden>` ("ask your admin"). | `<ModuleLocked>` with structured copy keyed on `reason` (plan_limit / overdue / suspended / cancelled / no_subscription). |
| Activation-code module picker | Hardcoded 7-module list. | Includes `zf`. Backend validator sourced from `Entitlements::ALL_MODULES` (single source of truth). |
| `/me.modules` derivation | Intersect of role permissions + plan modules. `zf` could never reach the FE because no permission key. | Tracks `entitlements.modules` directly (plus implicit `common`/`owner` umbrellas). |

---

## 2 · Backend module gates (final state)

| Module | Route group | Middleware | Where (`backend/routes/api.php`) |
|---|---|---|---|
| `retail` | `/catalog/*`, `/inventory/*`, `/receivings`, `/stocktakes`, `/transfers`, `/adjustments`, `/wastage`, `/suppliers`, `/rfqs`, `/purchase-orders` | `module:retail` | First group, ~line 483 |
| `retail` | `/campaigns`, `/loyalty/*`, `/gift-cards`, `/bundles`, `/layaways/*`, `/pos/*` | `module:retail` | Second group, ~line 657 |
| `dine` | `/dine/*` | `module:dine` | Existing, line 745 |
| `pay` | `/pay/*` | `module:pay` | Existing, line 707 |
| `accounting` | `/accounting/*` | `module:accounting` | Existing, line 892 |
| `hr` | `/hr/*` | `module:hr` | Existing, line 1060 |
| `ecom` | `/ecom/*` | `module:ecom` | Existing, line 1134 |
| `zf` | `/zf/*` | `module:zf` | New, ~line 1153 |
| (none) | `/customers`, `/crm/*`, `/branches`, `/users`, `/me/...`, `/workflows`, `/owner/*` | shared | unchanged |

`/customers` + `/crm/*` stayed un-gated because Dine reads customer
master data; gating them under `module:retail` would break dine-only
tenants. `/owner/*` is a portfolio view that doesn't fit any module.

---

## 3 · Frontend module gates (final state)

### Nav config

Every product nav file (`retail.nav.js`, `dine.nav.js`, `pay.nav.js`,
`accounting.nav.js`) and the workspace items (`ecom`, `hr`) declare
both `module` and `permission`. The sidebar filter in `AppShell.jsx`
applies both gates: `(no permission OR can) AND (no module OR hasModule)`.

Retail's ZF entry declares `module: 'zf'` (not `'retail'`) so it
disappears for tenants whose plan grants retail but not zf — exactly
the starter and growth tiers today.

### Session store

```js
useSessionStore.getState().hasModule('retail')   // → boolean
useSessionStore.getState().hasAnyModule(['retail', 'dine'])   // OR semantics
useSessionStore.getState().moduleAccess('accounting')
// → { enabled: false, reason: 'plan_limit', upgrade_required: true, is_premium: true }
```

### Route guards

```jsx
// Old (still works — back-compat preserved)
<ProtectedRoute permission="retail">…</ProtectedRoute>

// New (typed)
<ProtectedRoute permission="pos.sales.create" module="retail">…</ProtectedRoute>

// Standalone module gate (alias for RequirePerm shape)
<RequireModule module="retail">…</RequireModule>
```

The module check fires BEFORE the permission check, so a tenant
without the module sees `<ModuleLocked>` (with structured upgrade
copy), not the generic `<Forbidden>` ("ask your admin").

---

## 4 · Direct-URL behavior

| Caller | Route | Before | After |
|---|---|---|---|
| Dine-only tenant types `/api/v1/pos/sales` in curl | `/pos/sales` | 200 OK | 403 `MODULE_NOT_ENTITLED`, fields.module=['retail'] |
| Retail-only tenant fetches `/api/v1/dine/orders` | `/dine/orders` | 403 (already gated) | 403 (unchanged) |
| Retail-only tenant fetches `/api/v1/zf/jobs` | `/zf/jobs` | 200 OK | 403 `MODULE_NOT_ENTITLED`, fields.module=['zf'] |
| Enterprise tenant fetches `/api/v1/zf/overview` | `/zf/overview` | 200 OK | 200 OK |
| Tenant with no subscription fetches any module-gated route | any | 200 OK (openEverything) | 403 `TENANT_BLOCKED` or `MODULE_NOT_ENTITLED` |
| Demo tenant (slug=`acme`) | any | 200 OK | 200 OK |

FE behavior is consistent: nav items hidden, route guards render
`<ModuleLocked>` with the structured reason from the entitlements
payload.

---

## 5 · Superadmin / platform staff / impersonation rules

(Preserved from before; verified by existing tests.)

| Mode | Tenant module check |
|---|---|
| Platform cockpit (`mode='platform'`, not impersonating) | Bypass — platform staff don't have a tenant subscription. `EnsureTenantNotSuspended` exempts them; `ProtectedRoute` short-circuits when `isPlatformStaff && mode==='platform'`. |
| Internal mode (`mode='internal'`, demo tenant) | Demo bypass — `openEverything()` for slug=`acme`. |
| Impersonation (`mode='impersonation'`) | Gated as the impersonated tenant. A superadmin impersonating a starter tenant sees the same modules a starter user would. |
| Tenant user (`mode='tenant'`) | Gated per their subscription. |

Backend gates do NOT rely on superadmin visibility to compensate. The
existing `ModuleEntitlementGateTest` and the new
`ModuleEntitlementCompletionTest` exercise these from a tenant user
perspective, not superadmin.

---

## 6 · Demo and no-subscription behavior

```php
// Entitlements::for($companyId)
if ($isDemo) {
    return $this->openEverything(...);   // every module enabled
}
if (! $sub) {
    return $this->emptyEntitlements(...);   // every module locked with reason='cancelled'
}
// otherwise: snapshot-driven module_access
```

- **Demo tenant** (slug=`acme`): every module enabled, limits=999,
  status=active. Bypass is by slug, not by env. `Mode::DEMO_SLUG`.
- **No-subscription tenant** (non-demo): every module locked,
  `blocked: true`, `blocked_reason: 'no_subscription'`, limits=0.
  `EnsureTenantNotSuspended` returns 403 `TENANT_BLOCKED`. Module
  middleware would return 403 `MODULE_NOT_ENTITLED` — but tenant_active
  fires first.

---

## 7 · Plan / module matrix

| Plan id | Tier | Modules granted | Premium features |
|---|---|---|---|
| `starter` (199 SAR) | Entry | `retail`, `common` | — |
| `growth` (599 SAR) | Mid | `retail`, `dine`, `accounting`, `common` | multi_branch, advanced_reports |
| `enterprise` (1990 SAR) | Top | `retail`, `dine`, `pay`, `accounting`, `ecom`, `hr`, `common`, **`zf`** | all |
| `trial_unlimited` (0 SAR, activation code only) | Trial | every module incl. `zf` | every premium feature |

**ZF entitlement decision:** premium add-on. Granted by `enterprise`
and `trial_unlimited` only. Starter and growth tenants don't see the
ZF nav entry and get 403 on `/zf/*`. The ZF stubs themselves are not
touched — `ZfController::jobsRun` still returns `['stubbed' => true]`
for reconcile / shelf_scan / zatca_submit (see `ZF_AUDIT.md`); we just
prevent non-entitled tenants from reaching those stubs.

---

## 8 · Tests added (overview)

### Backend (16 new + entire suite still passing)

`backend/tests/Feature/ModuleEntitlementCompletionTest.php` — 16 tests:

- Retail surface gated against dine-only tenants: 5 tests
  (`/pos/sales`, `/catalog/products`, `/inventory/overview`,
  `/suppliers`, `/campaigns`).
- Dine surface gated against retail-only tenants: 3 tests
  (`/dine/orders`, `/dine/dashboard`, `/dine/menus`).
- ZF entitlement: 3 tests (retail-only blocked, growth-without-zf
  blocked, enterprise passes).
- Mixed retail+dine tenant: 1 test (both surfaces reachable).
- No-subscription (non-demo) tenant: 2 tests (every surface 403s;
  `/me/entitlements` payload reflects blocked state).
- Demo bypass: 1 test (still works post-tightening).
- Activation-code-style plan: 1 test (modules in the plan snapshot
  reach `module_access`).

All 16 pass.

### Frontend (30 new vitest tests)

- `app/src/core/session/__tests__/moduleSelectors.test.js` — 10 tests
  on `hasModule`, `hasAnyModule`, `moduleAccess`, and the
  modules-vs-permissions independence invariant.
- `app/src/modules/auth/__tests__/authSessionModules.test.js` — 6 tests
  on `toSessionPayload` extracting modules + entitlements from `/me`
  (login response + /me response shapes).
- `app/src/app/__tests__/navFilter.test.js` — 14 tests on the
  visible-items filter rule, plus a source-grep proof that `AppShell.jsx`
  applies it. Includes per-module nav-config assertions
  (every retail/dine/pay/accounting item declares a `module`).

All 30 pass. Total vitest: 221/221.

---

## 9 · Known limitations

1. **`Inventory/`, `Catalog/`, `Suppliers/` are gated as retail.** Some
   future modules (Wholesale, Van Sales) will also need catalog and
   inventory access. Per the audit's recommendation, this would
   eventually be split into "Catalog Core" + "Inventory Core" shared
   modules with retail-specific extensions (pricing rules, etc.) staying
   retail-only. Out of scope for this branch.
2. **`common` and `owner` are not explicitly gated.** They flow through
   as "always-on" umbrellas. This matches the existing `Entitlements`
   contract — they are core modules.
3. **The pre-existing flakes remain.** Three AP/AR step-up `419`s and
   one `TenantSubscriptionTest` MySQL JSON-key-order flake. All
   documented in `RETAIL_AUDIT.md` §13. Not introduced by this branch.
4. **Legacy permissions-alias still active.** `derivePermissions()`
   continues to inject module keys into the permissions array so
   `can('retail')` keeps working. New code should call `hasModule()`.
   Migration of legacy callsites is not done here.
5. **Frontend rendering tests are logic-only.** The test suite has no
   React-testing-library DOM rendering. Per the existing convention,
   we added logic + source-grep tests rather than introducing a new
   test stack.

---

## 10 · Rollout notes

This is a behavior change for two narrow tenant classes:

1. **Tenants without a subscription row.** These previously had
   `openEverything()` access; they now get `TENANT_BLOCKED`. The
   provisioning flow (`TenantProvisioningService::provision`) always
   assigns a plan, so newly-created tenants are unaffected. The risk
   surface is tenants that manually had their subscription cancelled
   without a replacement — they will now be blocked. **Action:**
   before rollout, query `tenant_subscriptions` for `effective_to IS
   NULL` rows; cross-reference with `companies`; any company missing
   an open row needs to be either:
   - re-assigned a plan (if they're a paying customer),
   - explicitly cancelled (correct new behavior), or
   - marked as the demo tenant (slug=`acme`).

2. **Tenants on starter/growth plans clicking `/zf/*` URLs.** They
   previously got 200; they now get 403. **Action:** none — these
   tenants were getting stub responses anyway (3 of 4 ZF kinds are
   stubs per `ZF_AUDIT.md`). Real customers haven't been using ZF on
   sub-enterprise plans.

Database migrations: none.

Seeder change: `enterprise` and `trial_unlimited` plan `modules`
fields gain `'zf'`. Re-running the seeder updates existing plan rows
via `updateOrCreate(['id' => ...])`. No data loss; modules array is
overwritten.

---

## 11 · Rollback notes

If the rollout exposes a tenant unexpectedly blocked:

1. **Quick mitigation:** restore `openEverything()` for no-sub tenants
   by editing `backend/app/Services/Platform/Entitlements.php:78` —
   change `if ($isDemo) {` back to `if ($isDemo || ! $sub) {`. This
   re-opens every surface for affected tenants while you triage. Single
   line change.
2. **Per-tenant mitigation:** assign them `trial_unlimited` via
   `php artisan tinker`:
   ```php
   app(\App\Services\Platform\SubscriptionService::class)
       ->assignPlan('<company-id>', 'trial_unlimited', ['change_reason' => 'mitigation']);
   ```
3. **Full revert:** `git revert b152b68..HEAD` on this branch reverts
   all six commits. The only schema change is plan seeders, which are
   idempotent on re-seed.

The retail / dine / zf gates themselves are safe to leave in place
even during a rollback — every plan grants at least retail+common, so
no tenant becomes unreachable unless they have no subscription at all
(which is the case we're handling).

---

## 12 · Next recommended Retail / Platform work

This branch closes the entitlement gap. Outstanding from prior audits:

1. **`retail-core-integrity`** (separate branch already exists): three
   POS integrity fixes (negative-stock guard, authoritative unit_price,
   payment sum validation). Land independently.
2. **Variants persistence** (`MERCHANDISING_AUDIT.md` MERCH-1): the
   variant builder UI in `AddProductPage` silently drops `form.variants`
   on submit. Needs FE payload wiring + BE validator + variant rows
   persisted in the same transaction as the parent product.
3. **ZF stub replacement** (`ZF_AUDIT.md` ZF-1): reconcile / shelf_scan
   / zatca_submit are stubs. Now that ZF is properly entitlement-gated,
   real implementations can ship per-customer without exposing free-tier
   tenants.
4. **Permissions split for catalog writes** (`MERCHANDISING_AUDIT.md`
   MERCH-2): `catalog.products.view` currently gates create / update /
   delete / import. Split into discrete permissions.
5. **`Catalog/` + `Inventory/` re-classification as shared core**: split
   so dine-only tenants can manage stock without retail entitlement.
   Pre-req for Wholesale / Van Sales modules.

---

## 13 · Commit list

```
b152b68 Phase 1: backend module entitlement completion (retail + zf + no-sub deny)
6e4f98e Phase 2+3: frontend module model + RequireModule + nav filtering
e0d3156 Phase 4: backend direct-URL tests for module entitlement
138c37b Phase 5: frontend tests for module model + nav filter + auth mapping
aeaa037 Phase 6: subscription/plan consistency — zf in pickers + /me passthrough
(this) Phase 8: documentation
```

---

---

## 14 · Pre-merge verification (final pass)

Strict verification run on 2026-05-11 against
`module-entitlement-completion` at HEAD `2a0c8b0`.

### Commands executed

| # | Command | Result |
|---|---|---|
| 1 | `git status` + `git log --oneline -8` | Working tree clean; 6 commits on branch, all pushed to origin |
| 2 | `php artisan security:audit-tenant-permissions` | ✅ clean — tenant↔platform boundary intact |
| 3 | `php artisan test --filter='Entitlement\|Subscription\|Module\|TenantProvisioning\|ActivationCode'` | 134 passed, 1 pre-existing flake (TenantSubscriptionTest JSON-key-order) |
| 4 | `php artisan test --filter='DirectUrl\|ModuleEntitlement\|EnsureTenantEntitlement'` | ✅ 21 passed, 0 failed |
| 5 | `php artisan test --filter='Retail\|Pos\|Dine'` | 325 passed, 3 pre-existing AP/AR step-up 419s |
| 6 | `php artisan test --filter=DineCashSaleAccountingPostingTest` | ✅ 1 passed |
| 6 | `php artisan test --filter='Journal\|Posting\|Accounting'` | 201 passed, 4 pre-existing step-up 419s (overlap with #5) |
| 7a | `npx vitest run` | ✅ 221 passed / 0 failed |
| 7b | `npm run build` | ✅ built in 2.15s, all chunks emitted |
| 8 | Manual code audit (below) | ✅ all 12 invariants confirmed |

### Pre-existing failures (5, all documented in `RETAIL_AUDIT.md §13`)

| Test | Cause | Status |
|---|---|---|
| `TenantSubscriptionTest > initial assignment creates open row with snapshot` | MySQL JSON-key-order: `[branches, users, devices]` vs `[users, devices, branches]`. Same keys, same values. | Pre-existing flake. Not introduced here. |
| `AccountingTest > bills create and pay partial then full` | `/accounting/bills/{id}/pay` returns 419 (step-up ticket required); test doesn't fetch one. | Pre-existing. Out of scope. |
| `ArApVoucherPostingTest > ap payment clears payable` | Same 419 on `/bills/pay`. | Pre-existing. |
| `ArApVoucherPostingTest > ap balance zeroes after full payment` | Same 419 on `/bills/pay`. | Pre-existing. |
| `ArApVoucherPostingTest > full ar ap voucher roundtrip` | Same 419 on `/bills/pay`. | Pre-existing. |

**Zero new failures introduced by this branch.**

### Manual code audit results (12 invariants)

| # | Invariant | Status | Evidence |
|---|---|---|---|
| 1 | `module:retail` wraps all Retail routes | ✅ | `routes/api.php:489` (`/catalog/*`, `/inventory/*`, suppliers, RFQs, POs, etc.) and `:668` (`/campaigns`, `/loyalty/*`, `/gift-cards`, `/bundles`, `/layaways/*`, `/pos/*`) |
| 2 | `module:zf` wraps `/zf/*` | ✅ | `routes/api.php:1171` |
| 3 | `zf` in `ALL_MODULES` | ✅ | `Entitlements.php:39` — `['retail', 'dine', 'pay', 'accounting', 'ecom', 'hr', 'common', 'zf']` |
| 4 | `zf` in `PREMIUM_MODULES` | ✅ | `Entitlements.php:46` — `['accounting', 'pay', 'hr', 'ecom', 'zf']` |
| 5 | starter does **not** get zf | ✅ | `DatabaseSeeder.php:2059` — `['retail', 'common']` |
| 6 | growth does **not** get zf | ✅ | `DatabaseSeeder.php:2081` — `['retail', 'dine', 'accounting', 'common']` |
| 7 | enterprise **gets** zf | ✅ | `DatabaseSeeder.php:2105` — `[..., 'common', 'zf']` |
| 8 | trial_unlimited **gets** zf | ✅ | `DatabaseSeeder.php:2133` — `[..., 'common', 'zf']` |
| 9 | No-subscription tenant gets `emptyEntitlements()` | ✅ | `Entitlements.php:94-96` — `if (! $sub) { … emptyEntitlements }` |
| 10 | Demo tenant gets `openEverything()` | ✅ | `Entitlements.php:90-92` — `if ($isDemo) { … openEverything }` |
| 11 | `/me` returns `modules` + `entitlements` | ✅ | `MeController.php:92-93` — both fields in the response payload |
| 12 | FE session keeps `modules` separate from `permissions` | ✅ | `sessionStore.js:37` (`modules: []`), `:41` (`entitlements: null`), `:161` (`hasModule: (mod) => …`) |
| 13 (bonus) | Nav uses `module + permission` filter | ✅ | `AppShell.jsx:317-318` — `if (it.permission && !can) return false; if (it.module && !g.platformOnly && !hasModule) return false;` |
| 14 (bonus) | Direct URL → disabled module shows `<ModuleLocked>` | ✅ | `ProtectedRoute.jsx:65-73` — `if (module && !hasModule(module)) return <ModuleLocked module={module} reason={…} />` |
| 15 (bonus) | `<ModuleLocked>` exists with structured copy | ✅ | `States.jsx:55` — `export const ModuleLocked = ({ module, reason })` with `REASON_COPY` keyed on plan_limit / overdue / suspended / cancelled / no_subscription |

### Tenant scenarios covered by tests

| Scenario | Backend tests | Frontend tests | Verdict |
|---|---|---|---|
| **Retail-only** | `ModuleEntitlementCompletionTest::test_retail_only_tenant_cannot_reach_dine_orders` / `_dashboard` / `_menus` / `_zf` (4 tests) | `navFilter.test.js`: hides dine + ZF nav, shows retail (3 tests) | ✅ |
| **Dine-only** | `_dine_only_tenant_cannot_reach_pos_sales` / `_catalog_products` / `_inventory_overview` / `_suppliers` / `_growth_campaigns` (5 tests) | `navFilter.test.js`: hides retail (1 test) | ✅ |
| **Mixed (retail + dine)** | `test_mixed_tenant_can_reach_both_surfaces` (1 test) | `navFilter.test.js`: shows both (1 test) | ✅ |
| **Enterprise (all incl. zf)** | `test_enterprise_tenant_can_reach_zf` + `test_activation_code_granted_modules_propagate_to_entitlements` (2 tests) | `navFilter.test.js`: shows ZF entry (1 test) | ✅ |
| **No-subscription (non-demo)** | `test_non_demo_tenant_without_subscription_is_blocked` + `test_no_subscription_tenant_me_entitlements_payload` (2 tests); plus `PlanLimitEnforcementTest::test_tenant_without_subscription_is_blocked` and `SubscriptionEnforcementTest::test_tenant_without_subscription_is_blocked_from_module_gated_routes` | `moduleSelectors.test.js`: `hasModule` returns false for empty modules (1 test) | ✅ |
| **Demo tenant (slug=`acme`)** | `test_demo_tenant_bypasses_all_module_gates` + existing `ModuleEntitlementGateTest::test_demo_tenant_bypasses_module_gate` (2 tests) | n/a (bypass is backend-only) | ✅ |
| **Overdue/suspended/cancelled** | Existing `SubscriptionEnforcementTest` suite (12+ tests) | n/a | ✅ |

### Merge safety verdict

**SAFE TO MERGE.** All checks green except for 5 pre-existing failures
documented in `RETAIL_AUDIT.md §13` (4 AP/AR step-up `419`s + 1
TenantSubscriptionTest MySQL JSON-key-order flake). None are introduced
by this branch. All entitlement and nav-filter invariants verified by
code audit. All 6 supported tenant scenarios (retail-only, dine-only,
mixed, enterprise, no-subscription, demo) are covered by tests. Frontend
build clean (`npm run build`: 2.15s, all chunks emitted). Vitest 221/221.

Rollout caveat from §10 still applies — query `tenant_subscriptions` for
non-demo tenants without an open subscription row before deploy.

**End of completion report. Tested clean. No deploy actions taken.**
