# DAL SEEN — Work log

A factual list of what was built across these sessions, on the **frontend** and on the **backend**. Use it to resume from any point.

---

## Where things live

| | Path | Run |
|---|---|---|
| **Frontend** (React + Vite) | `app/` | `cd app && npm run dev` → http://localhost:5173 |
| **Backend** (Laravel 11 + Sanctum + Spatie) | `backend/` | `cd backend && php artisan migrate:fresh --seed && php artisan serve --port=8800` → http://127.0.0.1:8800/api/v1 |
| Frontend env (points at backend) | `app/.env.local` (`VITE_API_BASE_URL=http://127.0.0.1:8800`) | — |
| Postman collection | `backend/docs/postman/dalseen-backend.postman_collection.json` | — |
| Backend implementation report | `backend/docs/IMPLEMENTATION-REPORT.md` | — |

### Demo accounts (all `password=password123`)

#### Tenant users (login uses workspace toggle, `company_slug=acme`)

| Email | Role | Lands on |
|---|---|---|
| `owner@acme.test` | business-owner | TenantDashboard |
| `manager@acme.test` | manager | TenantDashboard |
| `staff@acme.test` | staff | TenantDashboard |
| `cashier@acme.test` | cashier | redirected to /retail/pos kiosk |
| `accountant@acme.test` | accountant | TenantDashboard |

#### Platform staff (login uses platform-login toggle, no company_slug)

| Email | Role | Can do |
|---|---|---|
| `admin@acme.test` | superadmin | Everything (incl. impersonation, releases, internal staff CRUD) |
| `finance.manager@dalseen.sa` | finance-manager | Plans (write), Billing (refund/credit/remind/auto-hold), Collections; cannot suspend tenants or manage staff |
| `finance@dalseen.sa` | finance-employee | Read billing, Remind invoices, Work collections; cannot refund/credit |
| `support@dalseen.sa` | support-agent | Tickets (assign/resolve/reply), CRM write, Tenant message; cannot touch billing |
| `ops@dalseen.sa` | operations | Signups/Onboarding/Health/Incidents/Releases/Terminals; cannot touch billing |

---

## FRONTEND — what was done

### 1. Auth flows wired to the real backend
- `src/modules/auth/LoginPage.jsx` — two modes (workspace + platform). Toggle link below the submit button.
- `src/modules/auth/MfaVerifyPage.jsx` — 6-digit code (demo accepts `123456`).
- `src/modules/auth/ProfilePage.jsx`, `ChangePasswordPage.jsx` — `/me`, `/me/password`.
- `src/modules/auth/auth.api.js` — full Postman contract on `realRequest`.
- `src/modules/auth/auth.session.js` — bilingual mapper. Resolves role from `resp.user.roles[0]` OR top-level `roles` (fixed the platform-login regression where role got stomped to `null`).

### 2. Real-backend pages (zero `ErrorBanner` on these)
- **Catalog → Categories CRUD** — `src/modules/merchandise/CategoriesPage.jsx`
- **Catalog → Units CRUD** — `src/modules/merchandise/UnitsPage.jsx`
- **Ops → Branches** (list + detail + Ramadan profile + cloud kitchens) — `src/modules/ops/BranchesPage.jsx`, `BranchDetailPage.jsx`
- **Ops → Users** (CRUD + invite + sync roles + clear PIN with step-up) — `src/modules/ops/UsersAccessPage.jsx`
- **Ops → Roles** (CRUD + permission matrix) — `src/modules/ops/RolesPage.jsx`
- **Tenant Dashboard → Workflows + Notifications** cards — wired via `common.api.js` URL-prefix router (`/workflows` + `/notifications` go real, rest still mock).
- **Platform admin — entire sidebar**: Tenants, Signups, Onboarding, Plans, Billing, Support, Compliance, Health, Incidents, Releases, Audit, Pay terminals, CRM. All 13 pages render from real backend data via `platform.api.js` switched to `realRequest`.

### 3. Architectural restructure
- **Moved out of Retail, promoted to top-level**:
  - `src/modules/retail/ops/` → `src/modules/ops/` (Ops is cross-module, not retail-specific)
  - `src/modules/retail/catalog/` → `src/modules/merchandise/` (Merchandise is shared by Retail + Dine)
- **App.jsx routes** — added top-level `/ops/*` and `/merchandise/*`. Old `/retail/ops` and `/retail/catalog` redirect.
- **Sidebar (`src/app/nav.config.jsx`)** — new "Shared" group above "Books" with **Operations** + **Merchandise**.
- **Retail + Dine in-app nav** — both modules now show a `Catalog (shared)` and `Ops (shared)` cross-link that jumps to `/merchandise` and `/ops`.

### 4. Dev permission bypass
- `src/core/auth/devPermissions.js` — flag `VITE_ALLOW_ALL_PERMISSIONS=true` in `.env.local` short-circuits every `@can` / `RequirePerm` to allowed. Statically dead-code-eliminated in production builds (`vite build` strips the bypass branch even if the flag is set).
- Wired into `sessionStore.can/canAny/canAll`, `usePermission`, and `StepUp.useStepUp.request()` (skips PIN modal in dev).

### 5. Other small things
- `src/components/Icon.jsx` — added `user` icon for the new Account header link.
- `src/app/AppShell.jsx` — added Account icon link in the header next to Sign out.
- Real-HTTP interceptor (`src/core/api/realHttp.js`) — auto-attaches `X-Tenant-Id`, `X-Branch-Id`, `Idempotency-Key`/`X-Idempotency-Key`, consumes step-up tickets via `stepUpFor: 'permission.key'`. Tweaked to respect explicit `X-Branch-Id` overrides (needed for cloud-kitchens / ramadan-profile when querying a non-active branch).

### Frontend — what's still on mocks

| Module | Pages | Why |
|---|---|---|
| Retail · POS | sell flow, receipts, returns, returns wizard, CFD, fullscreen kiosk | No backend tables / endpoints yet |
| Retail · Inventory | overview, alerts, expiry, reorder, receivings, stocktakes, transfers, adjustments, wastage + 2 wizards | No backend tables / endpoints yet |
| Retail · Suppliers | list, marketplace, RFQs, RFQ detail, RFQ composer, POs, PO composer | No backend |
| Retail · Customers | list, segments, communications, tasks, birthdays, opportunities | No backend |
| Retail · Growth | campaigns, loyalty, gift cards, bundles, layaways | No backend |
| Retail · ZF | overview, learned, OCR, shelf, reconciliation, jobs, ZATCA, settings | No backend |
| **Merchandise → Products + Pricing + Resolver + Kinds** | Products list, AddProduct wizard, Pricing rules, Resolver, Kinds | Categories + Units are real; Products is the missing piece |
| Dine | every page (orders, KDS, menu, modifiers, recipes, combos, reservations, waitlist, aggregator, delivery, kiosk, QR, Ramadan-list, reports, dine POS) | No backend |
| Pay | charges, refunds, disputes, payouts, settlements, wallet, terminals, webhooks | No backend |
| Accounting | sell, spend, bank, CoA, GL, ZATCA, periods, vendors, customers, bills, vouchers, recurring, reports, audit, hub, opening, pay, collect, settings, VAT, journal | No backend |
| HR | staff, payroll, attendance, ATS, contracts, expenses, leave, performance, learning, roster, org | No backend |
| Owner | banking, goals, legal, merchants, home | No backend |
| E-commerce | catalog, customers, orders, returns, fulfillment | No backend |
| Common (remaining) | help center, marketplace, integrations, devices, partner inbox, the legacy roles page | No backend |

---

## BACKEND — what was done

Laravel **11.46** + PHP **8.4** + **SQLite** (dev) + **Sanctum 4** + **spatie/laravel-permission 6**.

### 1. Foundation
- `app/Exceptions/ApiException.php` — canonical error envelope `{ error: { code, message_en, message_ar, fields, correlation_id } }` with `X-Correlation-Id` header.
- `app/Support/ApiResponse.php` — success envelope helper (handles paginators, resources, raw arrays).
- `bootstrap/app.php` — global exception render maps Validation/Auth/Authorization/HTTP/NotFound → canonical envelope. API prefix set to `api/v1`.
- `app/Models/Concerns/BelongsToTenant.php` — global scope binds queries to `auth()->user()->company_id`, auto-fills `company_id` on create. Skipped during migrations / CLI / no auth.
- `app/Http/Middleware/EnsureTenantContext.php` (alias `tenant`) — fail-fast 403 for users without `company_id`.
- `app/Http/Middleware/EnsureSuperAdmin.php` (alias `superadmin`) — gates `/platform/*` to users with `superadmin` role.

### 2. Database — 14 application tables (+ Laravel defaults)
Migrations under `database/migrations/2026_05_05_*`:

| Table | Purpose |
|---|---|
| `companies` (extended) | tenants — slug, legal names, plan, mrr, status, region, users_count, branches_count, health_score, etc. |
| `users` (extended) | added `company_id`, `phone`, `preferred_locale`, `is_active`, `mfa_enabled`, `mfa_secret`, `pos_manager_pin_hash`, `invited_at` |
| `branches` | ULID, parent_branch_id (cloud kitchens), bilingual names, type, branch_type, address, ramadan profile fields |
| `branch_user` | pivot — primary_role, started_at, ended_at |
| `units` | catalog units of measure (per company) |
| `categories` | catalog categories (parent_id self-FK, bilingual) |
| `tenant_invoices` | platform-side billing |
| `tenant_signups` | pre-tenant pipeline |
| `health_services` | platform service snapshot |
| `platform_slos` | SLO definitions |
| `platform_incidents` | incident log |
| `platform_audit_log` | cross-tenant audit, ID + action + actor + severity + at |
| `workflows` | tenant + per-user task queue (dashboard "Next steps") |
| `notifications_feed` | tenant + per-user notification feed |
| `tenant_onboarding` | onboarding pipeline (current_step, completed[], risk) |
| `subscription_plans` | starter/growth/enterprise + features[] |
| `support_tickets` | platform support inbox |
| `compliance_items` | ZATCA, PDPL, NCA, SAMA, PCI, GAZT, MOL, ISO |
| `platform_releases` | release log (rolling, shipped, rolled-back, staging) |
| `platform_terminals` | cross-tenant POS / Soft-POS fleet |

### 3. API endpoints — 95 total under `/api/v1`

| Section | Count | Notes |
|---|---:|---|
| Auth (login + platform login + MFA + logout) | 4 | platform login = email + password, no slug |
| Me (show, update, change password, my branches) | 4 | |
| Catalog · Units (CRUD) | 4 | |
| Catalog · Categories (CRUD + bulk update + bulk delete) | 6 | |
| Branches (list + create + show + cloud-kitchens + ramadan get/patch + settings alias) | 7 | |
| Roles (full + summary + CRUD) | 6 | |
| Permissions (list) | 1 | |
| Users (CRUD + invite + sync roles + clear PIN) | 8 | |
| Workflows + Notifications (list + mark read + mark all read) | 4 | |
| **Platform — entire sidebar** | **48** | Tenants/Billing/Signups/Health/Incidents/Audit + Onboarding/Plans/Support/Compliance/Releases/Terminals + every detail + every write action |

Run `php artisan route:list --path=api` to see them.

### 4. Seeders — realistic demo data

Single `DatabaseSeeder.php` populates:

- 70 permission keys (`catalog.units.view`, `roles.create`, `platform.tenants.view`, …)
- 7 roles (superadmin, business-owner, manager, staff, cashier, accountant, auditor) with curated permission sets
- 1 home company (ACME, slug `acme`) + 12 demo tenants (Al-Nakheel, Olaya, Tahlia, Khobar Café, …)
- 6 users in ACME (the demo accounts above)
- 5 default units (pcs, kg, g, lb, box) + 6 categories + 2 branches (HQ + central kitchen)
- 9 tenant invoices, 10 signups, 13 health services + 5 SLOs, 6 incidents, 12 audit events
- 7 onboarding rows, 3 subscription plans, 8 support tickets, 8 compliance items, 6 releases, 12 terminals
- For ACME owner: 6 workflows + 6 notifications

All `firstOrCreate` / `updateOrInsert`, so re-seeding is idempotent.

### 5. Conventions baked in
- **Pagination** — list endpoints accept `?page=&per_page=`, return `{ data: [...], meta: { page, per_page, total } }`.
- **Bilingual names** — every model with `name_en` + `name_ar`. Locale-aware shaping in controllers via `Accept-Language` header.
- **Idempotency** — every POST/PUT/PATCH/DELETE auto-receives `Idempotency-Key` + `X-Idempotency-Key` from the realHttp interceptor. Backend doesn't yet store/dedupe by key (TODO).
- **Tenant scoping** — everything tenant-scoped flows through `BelongsToTenant`. Platform models live without that trait so superadmin reads cross-tenant.
- **Audit** — every platform write action records to `platform_audit_log` with actor email, role, IP, UA, severity. Tenant-side actions don't audit yet (TODO).

### Backend — what's NOT built

In rough priority for next session:

1. **Step-up middleware** — frontend sends `X-Step-Up-Ticket`, backend doesn't issue or verify. PIN modal currently bypassed in dev.
2. **Common module** (devices, integrations, marketplace, partner inbox, help center) — small surfaces, easy wins.
3. **Retail Products** — frontend has Products + Pricing + Resolver + Kinds; backend has Categories + Units only. Need `products` table + variants + pricing rules.
4. **Retail POS sales** — `sales`, `sale_lines`, `payments`, `shifts` tables + the moneymaker endpoints.
5. **Pay** — charges, refunds, settlements, payouts, terminals (tenant-side), webhooks.
6. **Dine** — orders, KDS, menu, modifiers, recipes, combos, reservations.
7. **Accounting** — chart of accounts, journal entries, periods, invoices, bills, ZATCA submissions.
8. **HR** — staff, payroll runs, attendance, leave, contracts.
9. **Tests** — none yet. Should add PHPUnit feature tests for the 95 wired endpoints.
10. **Production config** — currently SQLite + sync queue + log mail. For prod: Postgres + Redis + Horizon + S3 + Mailgun.
11. **Real audit on tenant actions** — only platform writes audit. Tenant-side catalog edits / branch creates don't.
12. **File uploads** — no avatars, no document upload, no CSV import yet.
13. **OpenAPI spec** — only Postman collection right now.

---

## Accounting Phase A2 — Customer accounting templates + terminology

Built between Phase A (foundation: account_mappings, JournalService idempotency, high-sev audit) and the upcoming Phase B (auto-posting). The user explicitly asked for the templates + terminology dictionary BEFORE the Phase B posters so each business type starts with the right chart shape and so the income/revenue mismatch can't break Phase C (frontend rewire).

### Backend — files added

| Path | Purpose |
|---|---|
| `backend/app/Support/AccountingTerms.php` | Dictionary: canonical account types, all known mapping keys (`KEYS`), bilingual labels (`LABELS`), type labels (`TYPE_LABELS`). Single source of truth for the accounting vocabulary. |
| `backend/app/Services/Accounting/Templates/ChartTemplate.php` | Abstract base class. `keyed()` pulls bilingual labels from `AccountingTerms`; `account()` is the escape hatch for template-specific accounts. |
| `backend/app/Services/Accounting/Templates/RetailTemplate.php` | Retail / Grocery — full POS tender mix, inventory, single revenue, COGS in 6xxx. |
| `backend/app/Services/Accounting/Templates/RestaurantTemplate.php` | Channel-split revenue (dine-in/takeaway/delivery/aggregator), ingredients + beverage inventory, food/beverage COGS, delivery platform fees, waste. |
| `backend/app/Services/Accounting/Templates/ServicesTemplate.php` | NO inventory, NO COGS. Service / subscription / project / consulting revenue, unbilled + unearned revenue, software & professional services expense. |
| `backend/app/Services/Accounting/Templates/EcommerceTemplate.php` | Online sales + shipping income, gateway clearing, gateway/processing fees, shipping expense, customer deposits. |
| `backend/app/Services/Accounting/Templates/ChartTemplateRegistry.php` | Slug → template class. `resolve()` falls back to retail; `resolveStrict()` throws `UNKNOWN_BUSINESS_TYPE`. |
| `backend/database/migrations/2026_05_05_180001_add_business_type_to_companies.php` | Adds `companies.business_type` (nullable, defaults to retail at provisioning). |
| `backend/tests/Feature/AccountingTemplatesTest.php` | 12 tests: per-template contracts, cross-template label consistency, services-no-inventory invariant, seeder template assignment, canonical type alignment. |

### Backend — files changed

- `backend/app/Services/Accounting/ChartProvisioner.php` — refactored from a single hardcoded chart into a registry-driven dispatcher. `ensure($companyId, ?$templateSlug = null)` now picks the template from `companies.business_type` (defaults to retail when null/unknown).
- `backend/app/Models/Company.php` — added `business_type` to `$fillable`.
- `backend/database/seeders/DatabaseSeeder.php` — ACME provisioned as retail (most general); the 12 demo tenants spread across all 4 templates: 5 retail · 3 restaurant · 2 ecommerce · 2 services.
- `backend/tests/Feature/AccountingPhaseATest.php` — updated to reference `RetailTemplate` instead of the removed `ChartProvisioner::DEFAULT_CHART`/`DEFAULT_MAPPINGS` constants. Per-tenant assertion now expects the right shape per `business_type`.

### Frontend — income/revenue mismatch fix

Backend canonical type is `'income'` (per `ChartAccount::TYPES`). Pre-A2 frontend mock + filters used `'revenue'`. Both layers now agree on `'income'` AND the frontend treats `'revenue'` as a tolerated alias so old mock data still renders.

| Path | Change |
|---|---|
| `app/src/modules/accounting/_shared/coa.constants.js` | NEW. `ACCOUNT_TYPES`, `INCOME_TYPES = new Set(['income','revenue'])`, `normalizeAccountType()`, `ACCOUNT_TYPE_LABELS`, `accountTypeLabel()`. Mirrors `App\Support\AccountingTerms` on the backend. |
| `app/src/modules/accounting/CoaPage.jsx` | KPI grid + badge tones keyed by canonical `'income'`; `normalizeAccountType()` applied so legacy `'revenue'` rows still render correctly. |
| `app/src/modules/accounting/VouchersPage.jsx` | Receipt picker uses `INCOME_TYPES.has(a.type) || a.type === 'liability'` (alias-safe). |
| `app/src/core/api/mocks/accounting.mock.js` | The 4 CoA rows previously typed `'revenue'` flipped to canonical `'income'`. P&L generator + trial-balance helper rewired through `INCOME_TYPES`. |

### Database changes

- `companies.business_type` (nullable string, 16 chars).
- No destructive changes to any existing accounting table.

### APIs added/changed

No new HTTP routes. Two implicit contract changes:

- `ChartProvisioner::ensure()` now accepts an optional `$templateSlug` (else reads `companies.business_type`).
- Provisioning a tenant produces different account counts per business type — no longer a single 25-row chart for everyone.

### Live cross-tenant probe

Confirmed against the dev DB after `migrate:fresh --seed`:

```
acme          retail      accounts=24  mappings=22
al-nakheel    retail      accounts=24  mappings=22
abha-foods    restaurant  accounts=27  mappings=24
khobar-cafe   restaurant  accounts=27  mappings=24
najran-cof    restaurant  accounts=27  mappings=24
dammam-log    services    accounts=23  mappings=20    ← no inventory, no COGS
yanbu-mar     services    accounts=23  mappings=20
madinah-bks   ecommerce   accounts=24  mappings=21
qassim-dat    ecommerce   accounts=24  mappings=21
…
```

### Tests

- `AccountingTemplatesTest` — 12 / 12 passing
- `AccountingPhaseATest` — 7 / 7 passing
- `AccountingTest` — 5 / 5 passing
- **Full backend suite — 123 tests, 1428 assertions, all green.**

### What is now real

- Four customer-facing business templates with correct account shape per type.
- Restaurants get channel-split revenue + food/beverage COGS + delivery platform fees out of the box.
- Services tenants explicitly cannot post inventory journals (no mappings exist) — auto-posters that try get a clean `ACCOUNT_MAPPING_MISSING` error instead of silently succeeding against a wrong account.
- E-commerce tenants get gateway clearing + shipping income/expense + customer deposits out of the box.
- The income/revenue mismatch is closed: backend ships `income`, frontend mock ships `income`, the alias `revenue` is tolerated everywhere via `INCOME_TYPES`.
- Tenant labels for shared mapping keys (e.g. `tax.vat_payable`, `tender.cash`, `inventory.cogs`) read identically across all four templates — enforced by a cross-template consistency test.

### What is still missing (Phase B and beyond)

- No POS / Dine / Pay / Inventory / AR / AP code is calling `JournalService::post()` yet — the auto-posters are Phase B.
- No template-aware auto-poster routing yet (e.g. POS in a restaurant should use `sales.dine_in`, not `sales.revenue`). This goes hand-in-hand with Phase B.
- Tenants cannot switch templates from the admin UI. Once Phase B+C land we'll need a one-time migration helper for tenants that change business type.

---

## Deferred translation platform — separate future track

Out of scope for this phase. Tracked here so it doesn't get lost. Triggered by the audit at the start of this session, which classified the current bilingual stack as **partially implemented and entirely static**.

The audit's findings stand: the Phase A2 work above is intentionally narrow — only the **accounting** vocabulary is now centralised and consistent. The full platform translation overhaul still needs to happen across these workstreams:

| Area | What's missing | Approx scope |
|---|---|---|
| **Dynamic translation catalog** | No JSON/PO/XLIFF files. Replace ~3,000 inline `t(en, ar)` pairs with key-based lookups against `/locales/en.json` + `/locales/ar.json`. | Mechanical, large. Module-by-module migration is feasible. |
| **Admin translation editor** | No UI today. Build a page that lists every key, shows EN / AR / tenant override side-by-side, lets staff edit & version. | Medium. ~1 week with a small UI. |
| **Tenant overrides** | No per-tenant rewording. Add `tenant_translation_overrides(company_id, namespace, key, lang, value)` + a resolver that prefers tenant value when present. | Small backend, small UI. |
| **Validation message translation** | Laravel `lang/ar/validation.php` is missing entirely → all per-field errors are English even in Arabic mode. | Small. Drop the published file + verify a few flows. |
| **`Accept-Language` middleware** | Header is sent on every request but no middleware calls `App::setLocale()`. As a result `MeController`, `LoginController`, `UserController` always return English `name` for branches even when the user is in Arabic mode. | Trivial. ~1 file. |
| **Audit action labels** | Audit log shows raw codes (`tenant.suspend`, `journal.post`). Need an `ACTION_LABELS` map (en/ar) used by the platform Audit page. | Small. |
| **`relativeTime` helper** | Returns `"5m ago"` / `"3d ago"` regardless of locale. | Trivial. |
| **Backend `ApiException` Arabic coverage** | ~half of `throw new ApiException(...)` calls pass `null` for `message_ar`. | Medium — ~60 call sites to backfill. |
| **Mock data gaps** | `dine.mock.js` has 17 EN-only ingredient names; `hr.mock.js` has 8 EN-only fields. | Small. |
| **Hijri / Latin digit toggle** | `Intl.NumberFormat('ar-SA')` produces Arabic-Indic digits; some Saudi accountants want Latin digits in printed reports. | Small UX toggle + setting. |

Anchor: when any of this lands, link the changes back to this section so the trail stays intact.

---

## Phase B stabilisation pass — cost accuracy + COGS classification

Mid-flight tightening between B1/B2 and B3. Two narrow fixes plus the inventory-model documentation the user asked us to make explicit.

### Step 1 — Retail return cost accuracy

**Problem**: `PosReturnPoster` was reading `product.cost_price` at refund time. If a tenant raised cost between sale and refund, the inventory restock + COGS reversal used the wrong value.

**Fix**: snapshot the cost on every sale line.

| File | Change |
|---|---|
| `backend/database/migrations/2026_05_05_190001_add_cost_at_sale_and_recipe_class.php` | New migration. Adds `pos_sale_lines.cost_at_sale` (decimal 14,4, nullable). |
| `backend/app/Models/PosSaleLine.php` | `cost_at_sale` in `$fillable` + cast as float. |
| `backend/app/Services/Pos/PosService.php` | Writes `cost_at_sale = product->cost_price` on every line at sale time. |
| `backend/app/Services/Accounting/Postings/PosSalePoster.php` | Prefers `line.cost_at_sale`, falls back to `product.cost_price` only for legacy sales. |
| `backend/app/Services/Accounting/Postings/PosReturnPoster.php` | Looks up the snapshot via `(sale_id, product_id)` — never current product cost. |

**Live proof**: sold 1 × CARD-250 at cost 28, raised cost to 35, refunded → inventory restock = 28, COGS reversal = 28. The current 35 is ignored.

### Step 2 — Dine food/beverage COGS split

**Problem**: `DineOrderPoster` lumped every recipe component into `cogs.food`. The Restaurant template ships `cogs.beverage` + `inventory.beverage` accounts that were never being used.

**Fix**: classify each recipe component, post one COGS leg per non-zero class.

| File | Change |
|---|---|
| `backend/database/migrations/2026_05_05_190001_add_cost_at_sale_and_recipe_class.php` | Adds `recipe_components.class` (string 16, default `'food'`). |
| `backend/app/Models/RecipeComponent.php` | `class` in `$fillable`, default `'food'` via `$attributes`. `CLASSES` const enumerates `food`/`beverage`/`other`. |
| `backend/app/Http/Controllers/Dine/DineController.php` | `recipesUpsert` validates and persists `class`. |
| `backend/app/Services/Accounting/Postings/DineOrderPoster.php` | `computeRecipeCogsByClass()` returns `['food' => X, 'beverage' => Y]`. Each non-zero bucket emits its own DR cogs.X / CR inventory.X pair. `'other'` rolls into food for v1 (Restaurant template has no packaging mapping yet). |

**Backward compat**: existing recipes without `class` get the column default `'food'`, so they post identically to before — no data migration needed.

### Step 3 — Restaurant inventory model documented

`backend/app/Services/Accounting/Templates/RestaurantTemplate.php` got a docblock spelling out the difference:

- **Retail tenants**: inventory = finished goods. Products are bought, stocked, sold AS-IS. POS sales decrement the same product the customer pays for. COGS = `inventory.cogs` against `inventory.asset`.
- **Restaurant tenants**: inventory = ingredients consumed through recipes. Menu items are NOT inventoried directly; their recipes reference real Products (buns, syrup, beans). When a Dine order is placed, ingredients are deducted from their products. When the order is paid, COGS splits by component class:
  - food / other → `cogs.food` / `inventory.ingredients`
  - beverage → `cogs.beverage` / `inventory.beverage`

Implication for accountants: a restaurant's BS "inventory" balance is raw ingredient value, not finished plates. Food and beverage cost on the P&L roll up from recipe consumption.

### Tests added

- `PosPostingTest::test_return_reverses_at_original_cost_even_after_price_change` — proves the snapshot wins over current cost.
- `DinePostingTest::test_food_only_recipe_posts_to_food_cost_only`
- `DinePostingTest::test_beverage_only_recipe_posts_to_beverage_cost_only`
- `DinePostingTest::test_mixed_recipe_posts_food_and_beverage_legs_separately`
- `DinePostingTest::test_recipe_component_without_explicit_class_defaults_to_food`

**Full backend suite: 148 tests, 1587 assertions, all green.**

---

## Phase P1 — Internal Platform Roles & Users (DAL SEEN staff)

Goal: replace the single all-powerful `superadmin` gate on the platform cockpit with proper internal roles. Today every DAL SEEN employee who needs to see anything platform-side has to be a superadmin (which also grants impersonation, releases rollback, internal staff CRUD). P1 fixes that without touching merchant flows.

### Schema

| File | Change |
|---|---|
| `backend/database/migrations/2026_05_05_210001_add_is_platform_staff_to_users.php` | `users.is_platform_staff` boolean default `false`, indexed. Backfilled to `true` for `admin@acme.test`. |
| `backend/app/Models/User.php` | New column added to `$fillable` and `$casts`. |

### Roles & permissions

5 platform roles total. Tenant roles unchanged.

| Role | Sees | Can mutate |
|---|---|---|
| `superadmin` | * | * (incl. impersonation, releases rollback, **internal staff CRUD**) |
| `finance-manager` | Tenants (list), Plans, Billing, Collections, CRM (read), Audit | Plans (write), Billing (refund/credit/remind/auto-hold), Collections (work) |
| `finance-employee` | Tenants (list), Billing, Collections, Audit | Invoice remind only, Collections (work) |
| `support-agent` | Tenants (list), Support, CRM, Onboarding, Signups, Audit | Tenant message, Tickets (assign/resolve/reply), CRM (write) |
| `operations` | Tenants, Signups, Onboarding, Health, Incidents, Releases, Compliance, Pay terminals, Support (read), Audit | Signups (promote/reject), Onboarding (work), Health/Incidents/Releases (act), Pay terminals (write) |

29 new permissions added (full breakdown in `DatabaseSeeder::seedPermissions()`):
- Tenants: `create / suspend / reactivate / impersonate / message / export / change_plan`
- Signups: `promote / reject`
- Onboarding: `work`
- Plans: `write`
- Support: `assign / resolve / reply`
- Billing: `create_invoice / refund / credit / remind / auto_hold`
- Collections: `view / work`
- Incidents: `create / resolve` · Health: `act` · Releases: `act` · Audit: `export`
- CRM: `view / write`
- Pay terminals: `write`
- **Internal staff: `view / manage`** (only superadmin holds `manage`)

### Middleware

| File | Purpose |
|---|---|
| `backend/app/Http/Middleware/EnsurePlatformStaff.php` | New group gate. Confirms `auth() && is_active && is_platform_staff` (legacy fallback: hasRole superadmin). |
| `backend/bootstrap/app.php` | Aliases `'platform' => EnsurePlatformStaff`, `'permission' => Spatie\Permission\Middleware\PermissionMiddleware`. Maps Spatie's `UnauthorizedException` to the canonical `FORBIDDEN` 403 envelope. |
| `backend/app/Http/Middleware/EnsureSuperAdmin.php` | Kept (alias `'superadmin'`) for the rare strict routes. |
| `backend/app/Support/Mode.php` | `Mode::current()` now treats any `is_platform_staff` user as `platform`/`internal` mode. Superadmins can still flip to `internal` for Internal Product Mode; other platform staff stay on `/platform/*`. |

### Routes

`backend/routes/api.php` — every `/platform/*` route now carries a per-action `permission:platform.x.y` middleware on top of the group `platform` gate.

### New controller

`backend/app/Http/Controllers/Platform/PlatformStaffController.php`

| Endpoint | Method | Permission |
|---|---|---|
| `/platform/staff` | GET | `platform.staff.view` |
| `/platform/staff/_meta/roles` | GET | `platform.staff.view` |
| `/platform/staff` | POST | `platform.staff.manage` |
| `/platform/staff/{id}` | GET | `platform.staff.view` |
| `/platform/staff/{id}` | PATCH | `platform.staff.manage` |
| `/platform/staff/{id}/deactivate` | POST | `platform.staff.manage` |
| `/platform/staff/{id}/reactivate` | POST | `platform.staff.manage` |
| `/platform/staff/{id}/reset-password` | POST | `platform.staff.manage` |

Safety rails (all 422 with explicit codes):
- `LAST_SUPERADMIN` — refuse to demote / deactivate the only active superadmin.
- `SELF_DEACTIVATE` — cannot deactivate your own account.
- `SELF_ROLE_CHANGE` — cannot change your own role.
- Role allow-list: only `superadmin / finance-manager / finance-employee / support-agent / operations` (no privilege escalation via tenant role names).
- Deactivation revokes all Sanctum tokens on the user → existing sessions die immediately.
- Reset password also revokes tokens; returns the temp password exactly once.

### Audit

Every mutation writes to `platform_audit_log`:
- `platform.staff.create` (severity high)
- `platform.staff.update` (medium)
- `platform.staff.role_change` (high)
- `platform.staff.deactivate` (high)
- `platform.staff.reactivate` (medium)
- `platform.staff.reset_password` (high)

### Backend auth changes

- `backend/app/Http/Controllers/Auth/LoginController.php` — `platformLogin` now admits any `is_platform_staff` user (not only superadmin). Same opaque error if the email doesn't qualify, so we don't leak which addresses are platform staff.
- `backend/app/Http/Controllers/MeController.php` — `/me` returns `is_platform_staff` so the frontend can render the platform sidebar group for non-superadmin staff.

### Seeder

- `DatabaseSeeder::seedPermissions()` extended with 29 new permissions.
- `seedRoles()` adds 4 new platform roles with the production matrix above.
- `seedUsers()` adds 4 demo platform staff (`finance.manager@dalseen.sa`, `finance@dalseen.sa`, `support@dalseen.sa`, `ops@dalseen.sa`) and flips `admin@acme.test.is_platform_staff = true`.

### Frontend

| File | Change |
|---|---|
| `app/src/modules/platform/StaffPage.jsx` | New page: list, KPIs, role-filter, status-filter, search, edit, deactivate/reactivate, reset password (one-time temp password modal). |
| `app/src/modules/platform/_shared/StaffEditorDialog.jsx` | New dialog: create + edit, role dropdown loaded from backend meta, validation surfacing per-field errors, superadmin assignment warning. |
| `app/src/modules/platform/platform.api.js` | 8 new `platformApi.*Staff*` methods. |
| `app/src/modules/platform/platform.hooks.js` | Hooks: `useStaffList`, `useStaff`, `useStaffRolesMeta`, `useCreateStaff`, `usePatchStaff`, `useDeactivateStaff`, `useReactivateStaff`, `useResetStaffPassword`. |
| `app/src/app/App.jsx` | Lazy-mounts `/platform/staff` (gate: `platform.staff.view`). |
| `app/src/app/nav.config.jsx` | New sidebar entry "Internal staff" + retargets the existing `/platform/crm` entry to the proper `platform.crm.view` permission. |
| `app/src/app/AppShell.jsx` | Platform sidebar group is now visible to any `is_platform_staff` user, not just superadmins. |
| `app/src/modules/auth/auth.session.js` | Hydrates `isPlatformStaff` from the backend `/me` payload. |
| `app/src/core/session/sessionStore.js` | Stores `isPlatformStaff`; `endImpersonation()` returns non-superadmin platform staff to `platform` mode (not `tenant`). |

### Tests

- `tests/Feature/PlatformRolesTest.php` — pins the production permission matrix for all 4 narrow roles (5 cases).
- `tests/Feature/PlatformStaffControllerTest.php` — CRUD + safety: list filtering, create+temp-password, role validation, duplicate email, only-superadmin gating, role change audit, last-superadmin protection (demote + deactivate), self-deactivate, deactivate→reactivate round trip, reset password (revokes sessions), 404 for tenant users, roles meta (14 cases).
- `tests/Feature/PlatformPermissionGatingTest.php` — end-to-end gate: SA suspends, FM cannot suspend; FE can remind, cannot refund; FM can refund/credit/auto-hold; SU can message, cannot change plan; OP cannot view billing; SA+OP can view releases; tenant users blocked from `/platform/*`; disabled platform user rejected; only SA can manage staff (14 cases).
- `tests/TestCase.php` — `actingAsPlatformStaff($role)` helper; `ensurePlatformRoleMatrix()` mirrors the production matrix for tests that need precise role boundaries.

**Result: 33 new tests, 136 assertions. Full backend suite: 250 tests, 2028 assertions, all green.**

---

## Phase P2 — Subscription Plans & Entitlements

Goal: make plans enforceable, not just descriptive. Plans now define modules, premium features, device caps and add-ons. Every tenant has an immutable history of what plan they were on and when. Branches/users/devices are blocked at the API when over the plan limit. The demo tenant (ACME) bypasses all limits.

### Schema

| File | Change |
|---|---|
| `2026_05_05_220001_extend_subscription_plans.php` | Adds `devices`, `modules` (json), `premium_features` (json), `addons` (json), `is_active`, `is_default`, `sort_order`, `billing_cycle` to `subscription_plans`. |
| `2026_05_05_220002_create_tenant_subscriptions_table.php` | New table: `id` (ulid), `company_id`, `plan_id`, `status` (trial/active/overdue/suspended/cancelled), `billing_cycle`, `period_start/end`, `effective_from/to`, snapshots (`limits`, `modules`, `premium_features`, `addons`), `price_*_at_signup`, `changed_by_user_id`, `change_reason`, `note`. Indexed by `(company_id, effective_from)` and `(company_id, effective_to)`. Type-2 SCD pattern. |

### Models

- `App\Models\SubscriptionPlan` — full CRUD model, cast to arrays for the JSON columns.
- `App\Models\TenantSubscription` — `current()` scope (effective_to IS NULL), relations to `Company`, `SubscriptionPlan` and `User`.
- `App\Models\Company::subscriptions()` and `currentSubscription()`.

### Services

- `App\Services\Platform\SubscriptionService`
  - `assignPlan($companyId, $planId, $opts)` — closes the previous open row, opens a new one with limits/modules/features snapshot, syncs the denormalised cache on `companies.plan / status / mrr / max_branches / max_users` inside one DB transaction. Validates plan exists + is_active.
  - `current($companyId)` / `history($companyId)`.
  - `setStatus($companyId, $status, $reason, $note)` — for P5 auto-transitions (overdue → suspended).
- `App\Services\Platform\Entitlements`
  - `for($companyId)` — full payload: plan, status, period, limits, usage, modules, premium_features, addons, is_demo. Per-request memoised. Add-on grants stack on base limits.
  - `hasModule($companyId, $module)`, `hasFeature($companyId, $feature)`, `usage($companyId)`.
  - Demo tenant short-circuit: any tenant with `slug = Mode::DEMO_SLUG` ('acme') gets unlimited access to everything.
  - Tenants without an open subscription row also get unlimited access (migration safety).
- `App\Services\Platform\LimitEnforcer`
  - `assertCanCreateBranch / User / Device` → throws `PLAN_LIMIT_EXCEEDED` (422) with `limits`, `usage`, `limit_kind`, `plan` in the error envelope so the UI can render upgrade prompts.
  - 999 = unlimited convention (matches the existing PlansPage display).

### Middleware

- `App\Http\Middleware\EnsureTenantEntitlement` (alias `module:`)
  - Use as `Route::middleware(['auth:sanctum','tenant','module:accounting'])`.
  - Throws `MODULE_NOT_ENTITLED` (403) with the missing module + currently-enabled list. Demo tenant + tenants without a subscription pass through.
  - Wired into the middleware aliases but NOT applied to production module routes yet (intentional: that's a one-line change per route group, deferred to P5 when we also add the auto-suspend / overdue-block logic).

### Endpoints (added)

| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | `/me/entitlements` | (any auth) | Tenant-side: full plan/limits/usage payload. |
| GET | `/platform/tenants/{id}/subscription` | `platform.subscriptions.view` | Current subscription + entitlements payload. |
| GET | `/platform/tenants/{id}/subscription/history` | `platform.subscriptions.view` | All historical rows newest-first. |

### Endpoints (changed)

- `POST /platform/tenants/{id}/plan` — now flows through `SubscriptionService::assignPlan()`. Accepts `plan` (must exist), optional `billing_cycle`, `effective_from`, `change_reason` (upgrade/downgrade/renewal/manual/trial_start/initial/auto_*), `note`. Plan id must be a real plan id (was previously hardcoded enum).
- `POST /platform/plans` and `PATCH /platform/plans/{id}` — now accept `devices`, `modules`, `premium_features`, `addons`, `is_active`, `is_default`, `sort_order`, `billing_cycle`. Modules and premium_features are validated against fixed allow-lists. Plan id allows hyphens (`pro-test`).
- `DELETE /platform/plans/{id}` — soft-delete (sets `is_active=false`), preserving snapshots for tenants on that plan.
- `GET /platform/plans` — uses the model, recomputes live `tenants_count` from open subscription rows, accepts `?include_inactive=1`.
- `GET /me` — adds `is_platform_staff` (P1 already) and a new `entitlements` block; intersects the user's module list with the tenant's enabled modules so a starter-plan accountant doesn't see "accounting" in the `modules` array.

### Permissions / roles

- New permission: `platform.subscriptions.view`.
- Granted to: superadmin, finance-manager, finance-employee, support-agent, operations.
- `platform.tenants.change_plan` is **also** granted to finance-manager (was only superadmin in P1).

### Limit enforcement wired into

- `BranchController::store` → `assertCanCreateBranch`.
- `UserController::store` → `assertCanCreateUser`.
- `Common\DeviceController::store` → `assertCanCreateDevice`.
- `Platform\PlatformController::issueTerminal` → `assertCanCreateDevice` (combined cap with tenant-side devices).

### Seeder

- `seedPlans()` rewritten with the extended structure:
  - **Starter** — 1 branch / 5 users / 2 devices / modules `[retail, common]` / no premium / 2 addons (extra_branch, extra_user).
  - **Growth** — 5 / 25 / 10 / `[retail, dine, accounting, common]` / `[multi_branch, advanced_reports]` / 4 addons.
  - **Enterprise** — 999 / 999 / 999 / all 7 modules / all 7 premium features / no addons.
- `backfillTenantSubscriptions()` — runs LAST; gives every existing company an open `tenant_subscriptions` row matching its `companies.plan` and mapped status. Idempotent.

### Frontend

| File | Change |
|---|---|
| `app/src/modules/platform/_shared/PlanEditorDialog.jsx` | Rewritten as a sectioned form: Identity & pricing, Limits (incl. devices), Enabled modules (multi-select pills), Premium features (multi-select pills), Marketing features (textarea), Add-ons (key/name/price + grants per row, add/remove), Visibility (is_active / is_default toggles). |
| `app/src/modules/platform/_shared/TenantDetailDrawer.jsx` | Adds a new "Subscription" tab with current sub card (status badge, limits/usage bars, enabled modules) and a chronological history list. PlanChangeDialog now collects billing_cycle + change_reason + note, displays each plan's modules. |
| `app/src/modules/platform/platform.api.js` | `tenantSubscription(id)` and `tenantSubscriptionHistory(id)`. |
| `app/src/modules/platform/platform.hooks.js` | `useTenantSubscription`, `useTenantSubscriptionHistory`, updated `useChangeTenantPlan` to accept the full body. |
| `app/src/modules/owner/EntitlementsCard.jsx` | New tenant-side card showing real plan / status / period / limits-vs-usage bars / enabled modules / premium features. Uses `/me/entitlements`. Shows demo-mode pill for the ACME tenant. |
| `app/src/modules/owner/OwnerHomePage.jsx` | Slots `EntitlementsCard` at the top (alongside the existing mock-data card so neither breaks). |
| `app/src/modules/auth/auth.api.js` | New `entitlements()` method. |
| `app/src/modules/auth/auth.hooks.js` | New `useEntitlements()` hook (30s cache). |

### Tests

| File | What it pins down |
|---|---|
| `TenantSubscriptionTest.php` (7 cases) | Initial assignment captures snapshot; reassign closes previous + opens new; companies cache (plan/mrr/max_*) stays in sync; yearly cycle sets year period and divides yearly price by 12 for mrr; setStatus carries plan through; PLAN_NOT_FOUND / PLAN_INACTIVE error codes. |
| `EntitlementsTest.php` (4 cases) | Demo tenant always unlimited; starter only has its modules; addon grants stack on base limits; usage reflects real branch/user/device counts. |
| `PlanLimitEnforcementTest.php` (7 cases) | Branch limit blocks the (cap+1)th branch; 999 acts as unlimited; user limit blocks the (cap+1)th user; device limit blocks the (cap+1)th device; platform-issued terminals count against the same cap; demo tenant bypasses even on a tight starter plan; tenant without a subscription passes through. |
| `PlatformSubscriptionApiTest.php` (7 cases) | changeTenantPlan records history; rejects unknown plan; GET subscription returns current + entitlements; finance-employee can read but not change; createPlan accepts modules/devices/premium_features/addons; rejects unknown module key; deletePlan is soft-delete and surfaces with `?include_inactive=1`. |
| `ModuleEntitlementGateTest.php` (5 cases) | `module:accounting` middleware allows tenants with the module, blocks tenants without it (403 MODULE_NOT_ENTITLED), demo tenant bypasses; /me filters modules by tenant entitlements; /me/entitlements returns the payload. |

**Result: 30 new tests, 125 assertions. Full backend suite: 280 tests, 2153 assertions, all green.**

---

## Phase P3 — Platform Billing

Goal: real DAL SEEN → tenant invoicing. Generate invoices with proper VAT math, record real payments (full / partial / multiple), keep an outstanding-balance ledger, and let Finance Manager / Employee work the lifecycle without anyone touching SQL.

Out of scope (deferred): collections workflow / dunning (P4), platform-side accounting ledger (P6), automatic suspension on overdue (P5).

### Schema

| File | Change |
|---|---|
| `2026_05_05_230001_extend_tenant_invoices.php` | Adds `tenant_subscription_id`, `period_start/end`, `billing_cycle`, `subtotal`, `vat_rate`, `vat_amount`, `total`, `paid_amount`, `refund_amount`, `currency_code`, `notes`, `created_by_user_id`, `cancelled_at`, `cancelled_reason`, `refunded_at`, `subscription_snapshot` (json). Backfills legacy rows: subtotal = total / 1.15, vat = total - subtotal, paid_amount = total when status='paid', renames legacy `pending` → `issued`. |
| `2026_05_05_230002_create_tenant_payments_table.php` | New table: `id` (ulid), `tenant_invoice_id` (FK, restrict-on-delete), `company_id`, `amount`, `currency_code`, `method` (bank_transfer/cash/card/wallet/other), `reference_number`, `bank_account`, `received_at` (date), `received_by_user_id` (FK users), `notes`. Indexed by `(company_id, received_at)` and `(tenant_invoice_id)`. |

### Models

- `TenantInvoice` extended: full fillable list + casts; `payments()` `hasMany`; `subscription()` `belongsTo`; `createdBy()` `belongsTo`; `outstanding` accessor; `isPayable()` helper.
- `TenantPayment` (new): full fillable + casts, `invoice()` / `company()` / `receivedBy()` relations, `METHODS` constant for the allow-list.

### Services

- `App\Services\Platform\InvoiceNumberer`
  - `next($year = null)` — returns the next sequential id (`INV-YYYY-NNNNN`, 5-digit zero-padded). Per-year sequence. Wraps in a DB transaction with `lockForUpdate` for race-safety.
- `App\Services\Platform\BillingService`
  - `createInvoice($companyId, $opts)` — creates a DRAFT invoice. Snapshots the tenant's current subscription (plan_id, billing_cycle, prices, limits, modules, premium features). Defaults: subtotal = current sub price, VAT = 15% (KSA), period = current month / current sub period.
  - `issue(invoice, $dueAt = null)` — DRAFT → ISSUED. Default 15-day net terms. Rejects with `INVOICE_NOT_DRAFT` if not draft, `INVOICE_ZERO_TOTAL` if total = 0.
  - `cancel(invoice, $reason = null)` — DRAFT/ISSUED/OVERDUE → CANCELLED. Rejects `INVOICE_HAS_PAYMENTS` if any payments recorded, `INVOICE_NOT_CANCELLABLE` if status is paid/cancelled/refunded.
  - `refund(invoice, $amount, $reason = null)` — ISSUED/PAID/OVERDUE → REFUNDED. Rejects `INVOICE_NOT_REFUNDABLE` for draft/cancelled, `INVALID_REFUND_AMOUNT` for ≤0, `REFUND_EXCEEDS_TOTAL` if amount > total.
  - `recordPayment(invoice, $data)` — appends to `tenant_payments`, increments `paid_amount`, flips invoice to `paid` when paid_amount ≥ total (within 1 halala). Rejects `INVOICE_NOT_PAYABLE` (draft/cancelled/refunded), `INVALID_PAYMENT_AMOUNT` (≤0), `OVERPAYMENT` (> remaining), `INVALID_PAYMENT_METHOD` (not in allow-list).
  - All mutations write to `platform_audit_log` with appropriate severity.

### Endpoints (added)

| Method | Path | Permission |
|---|---|---|
| POST | `/platform/billing` | `platform.billing.create_invoice` |
| GET | `/platform/billing/outstanding` | `platform.billing.view` |
| POST | `/platform/billing/{id}/issue` | `platform.billing.issue` |
| POST | `/platform/billing/{id}/cancel` | `platform.billing.cancel` |
| GET | `/platform/billing/{id}/payments` | `platform.billing.view` |
| POST | `/platform/billing/{id}/payments` | `platform.billing.record_payment` |
| GET | `/platform/tenants/{id}/billing` | `platform.billing.view` |

### Endpoints (changed)

- `POST /platform/billing/{id}/refund` — was an audit-only stub; now flows through `BillingService::refund` (real status transition, refund_amount, refunded_at, audit log).
- `GET /platform/billing/{id}` — returns the full new shape (subtotal/VAT/total/paid_amount/outstanding/period/billing_cycle/subscription_snapshot/payments).
- `GET /platform/billing` (dashboard) — surfaces all the new columns and KPIs. Adds `outstanding_total`, `open_invoices_count`, `paid_30d`, `collection_rate`.

### Permissions added

- `platform.billing.issue` — superadmin, finance-manager.
- `platform.billing.cancel` — superadmin, finance-manager.
- `platform.billing.record_payment` — superadmin, finance-manager, finance-employee.

Existing P1 permissions reaffirmed:
- `platform.billing.view` — SA, FM, FE.
- `platform.billing.create_invoice` — SA, FM.
- `platform.billing.refund` / `credit` / `auto_hold` — SA, FM.
- `platform.billing.remind` — SA, FM, FE.

Operations and Support staff continue to have **no** billing access.

### UI changes

| File | Change |
|---|---|
| `app/src/modules/platform/BillingPage.jsx` | Rebuilt: search + status filter (draft/issued/paid/overdue/cancelled/refunded/all), 6 KPI tiles (MRR, Paid 30d, Outstanding, Overdue, Open invoices, Collection rate), table now shows total + VAT + paid/outstanding split + period + status, "+ New invoice" button gated by `platform.billing.create_invoice`. |
| `app/src/modules/platform/_shared/BillingDetailDrawer.jsx` | Rewritten with two tabs: Summary (subtotal/VAT/total/outstanding/period/dates/subscription snapshot) and Payments (list + Record payment button). Action footer is permission- and status-aware: Issue/Cancel for draft, Record/Refund/Cancel/Remind for issued, Refund-only for paid, etc. |
| `app/src/modules/platform/_shared/InvoiceCreateDialog.jsx` (new) | Tenant picker + period dates + billing cycle + VAT + optional subtotal override + notes. Reads the tenant's current subscription via `useTenantSubscription` to show the snapshot price hint. |
| `app/src/modules/platform/_shared/RecordPaymentDialog.jsx` (new) | Amount (with outstanding hint + client-side guard against overpayment), method dropdown, reference number, bank account, received date, notes. Surfaces backend field errors. |
| `app/src/modules/platform/platform.api.js` | Adds `createInvoice`, `issueInvoice`, `cancelInvoice`, `listPayments`, `recordPayment`, `billingOutstanding`, `tenantBilling`. Refund/cancel now take a body. |
| `app/src/modules/platform/platform.hooks.js` | Adds `useCreateInvoice`, `useIssueInvoice`, `useCancelInvoice`, `useRecordPayment`, `usePayments`, `useOutstanding`, `useTenantBilling`. Refund hook accepts `reason`. All write hooks invalidate the relevant cache keys. |

### Seeder

`seedTenantInvoices()` rewritten to use BillingService:
- For each demo tenant: 2 fully-paid prior-month invoices + 1 current-month invoice in a varied state (paid / partial / overdue / freshly issued / extra cancelled).
- Run order moved: `seedPlans()` + `backfillTenantSubscriptions()` now run BEFORE `seedTenantInvoices()` so the BillingService can snapshot a real subscription per invoice.
- Demo data: 28 invoices created (18 paid, 1 partial, 3 overdue, 1 cancelled) across 9 tenants.

### Tests

| File | What it pins down |
|---|---|
| `BillingServiceTest.php` (14 cases) | Create snapshots subscription + computes 15% VAT correctly; id format is `INV-YYYY-NNNNN`; numberer increments sequentially; issue flips draft→issued + sets due date; issue rejects non-draft; full payment flips status to paid; partial payments keep invoice open until total met; overpayment is rejected; payment on draft/cancelled is rejected; cancel works on draft/issued only when no payments; cancel rejects with payments; refund only on issued/paid/overdue; outstanding accessor handles all states; audit log records all 5 sensitive actions. |
| `PlatformBillingApiTest.php` (8 cases) | Full round trip create→issue→record payment via REST; finance-employee can record payment but cannot create; FE cannot cancel or refund; outstanding endpoint returns grouped per-tenant summary with KPIs; tenant-billing endpoint scopes to one tenant; payments list endpoint returns history with totals; validation errors surface as 422 (missing company_id, invalid method, negative refund); support-agent gets 403 on every billing read. |

**Result: 22 new tests, 83 assertions. Full backend suite: 302 tests, 2236 assertions, 55.26s. All green.**

### Live verification (curl on `php artisan serve`)

| Check | Result |
|---|---|
| `GET /platform/billing` for SA | 28 invoices; KPIs: MRR=12151, Paid 30d=19699.5, Outstanding=9623.20, Open=9, Collection rate=65%. |
| `GET /platform/billing/outstanding` for FE | 9 open / 9623.20 outstanding / 3 overdue; per-tenant top-list shows largest balances first (al-nakheel, dammam-log, qassim-dat, …). |
| `POST /platform/billing` for FM (create draft) → `POST /issue` → `POST /payments` × 2 (FE then FM) | Draft id `INV-2026-00029` with total 2288.50; issued with due_at auto-set 15 days out; partial 200 by FE leaves outstanding 2088.50, status=issued; final 2088.50 by FM closes status=paid with paid_at set. |
| Permission matrix on key endpoints | `outstanding`: SA/FM/FE 200, OP/SU 403. `POST /billing` (create): SA/FM 201, FE/OP/SU 403. `POST /billing/{id}/payments`: SA/FM/FE 201, OP/SU 403. |
| Audit log | Captured `invoice.create`, `invoice.issue`, `invoice.cancel`, `invoice.refund`, `payment.record` rows from the live actions. |

### Remaining gaps for P4 collections (deferred)

- **Auto-overdue sweep** — invoices past `due_at` aren't automatically flipped to `status='overdue'` yet. P4 (or a small scheduled job inside P4) will handle this.
- **Aging buckets** — outstanding endpoint groups by tenant but doesn't yet bucket by 1-30 / 31-60 / 61-90 / 90+ days past due (P4).
- **Collection notes / follow-up date / promise-to-pay date / assignee** — `tenant_invoices.notes` exists for free-text notes, but there's no dedicated `collection_notes` table with workflow state (P4).
- **Collection status enum** (`not_started / contacted / promised / escalated / paid / written_off`) — not modelled (P4).
- **Bank statement reconciliation** — `tenant_payments.reference_number` + `bank_account` capture the data, but there's no import/match flow (deferred indefinitely; manual entry for now).
- **Auto-suspend on overdue** — `companies.status='overdue'` and `'suspended'` exist but no enforcement (P5).
- **Premium-feature route gates** — entitlements layer is ready (P2), but no route uses it yet (P5).
- **DALSEEN's own books / SaaS revenue recognition** — no journal posting on invoice issuance or payment receipt (P6).
- **Recurring billing job** — invoices today are minted manually via `POST /platform/billing` or by the seeder. There's no monthly cron (intentional; can be added in P4 or as a separate small task).
- **Invoice PDF rendering / email delivery** — out of scope for P3 (the data model carries everything needed).

---

## Phase P4 — Finance / Collections

Goal: a usable, simple collections workflow for DAL SEEN finance. Track which invoices are being chased, by whom, what stage, and surface aging buckets so the team can prioritise. No automation, no notifications — just human workflow.

Out of scope: auto-overdue/auto-suspend (P5), email/SMS reminders, premium-feature gates (P5), DAL SEEN's own books (P6).

### Schema

| File | Change |
|---|---|
| `2026_05_05_240001_add_collection_fields_to_tenant_invoices.php` | Adds `collection_status` (string default `not_started`), `follow_up_date`, `promise_to_pay_date`, `assigned_finance_user_id` (FK users, nullOnDelete) on `tenant_invoices`. Indexed `(collection_status, follow_up_date)` + `assigned_finance_user_id`. Backfills paid invoices to `collection_status='paid'`. |
| `2026_05_05_240002_create_collection_notes_table.php` | New `collection_notes` table: `id` (ulid), `tenant_invoice_id` (FK cascade), `company_id` (FK cascade), `user_id` (FK users nullOnDelete — null for future system events), `note` (text). Indexed `(tenant_invoice_id, created_at)`. |

### Models

- `TenantInvoice` extended:
  - `COLLECTION_STATUSES` constant.
  - 4 new `$fillable`s, two new `date` casts.
  - `assignedTo()` `belongsTo`, `collectionNotes()` `hasMany` newest-first.
- `CollectionNote` (new): `invoice()` / `user()` / `company()` relations.

### Service

- `App\Services\Platform\CollectionsService`
  - `updateStatus()` — validates the closed enum; rejects manual `paid` (must come from a real payment); rejects unknown values. Audits `collection.status` (medium) or `collection.write_off` (high).
  - `setFollowUp()` and `setPromiseToPay()` — settable + clearable.
  - `assign()` — validates assignee is `is_platform_staff`; idempotent; audits.
  - `addNote()` — rejects empty notes; creates row; audits.
  - `bucketFor(invoice)` and `bucketForDays(int)` — pure functions returning `current` / `1_30` / `31_60` / `61_90` / `90_plus`. Carbon 3 safe (uses `abs()` since Carbon 3 returns signed diffs by default).
  - `agingSummary(rows)` — folds shaped invoice rows into the 5-bucket `{count, outstanding}` map for the dashboard.

### BillingService change

`BillingService::recordPayment` now sets `collection_status='paid'` automatically when the payment closes the invoice — except when the invoice was already manually `written_off` (so a manager's decision isn't silently overwritten by a late payment).

### Permissions added (2)

- `platform.collections.assign` — finance-manager + superadmin.
- `platform.collections.write_off` — finance-manager + superadmin.

(P1 perms reaffirmed: `platform.collections.view` and `platform.collections.work` already grant FM, FE.)

### Endpoints (added)

| Method | Path | Permission |
|---|---|---|
| GET   | `/platform/collections/queue` | `platform.collections.view` |
| PATCH | `/platform/billing/{id}/collection` | `platform.collections.work` (extra `write_off` perm required for that status) |
| GET   | `/platform/billing/{id}/notes` | `platform.collections.view` |
| POST  | `/platform/billing/{id}/notes` | `platform.collections.work` |
| POST  | `/platform/billing/{id}/assign` | `platform.collections.assign` |

### Endpoints (changed)

- `GET /platform/billing/outstanding` — KPIs now include `aging` (5 buckets, each with count + outstanding); rows include `collection_status / follow_up_date / promise_to_pay_date / assigned_to / aging_bucket / days_past_due`.
- `GET /platform/billing` (dashboard list) and `GET /platform/billing/{id}` — same enrichment so the table can render collection state without follow-up requests.

### UI changes

- **BillingPage** — new aging KPI strip (5 cards: current / 1–30 / 31–60 / 61–90 / 90+), each with outstanding amount + invoice count. Table now has 3 new columns: Collection (status badge), Assignee (name/email or "unassigned"), Aging (bucket badge + days past due).
- **BillingDetailDrawer** — third tab "Collections":
  - Status dropdown (FM + FE write; `written_off` only listed for users with `collections.write_off`).
  - Assignee dropdown sourced from `/platform/staff` (FM only — read-only label for FE).
  - Follow-up + Promise-to-pay date pickers.
  - Days-past-due / aging-bucket / outstanding mini-summary.
  - Notes feed with append-only entries showing author + timestamp; "Add note" composer for users with `collections.work`.

### Seeder

`seedTenantInvoices()` now calls `seedCollectionWorkflow()` after creating invoices:
- Round-robin assigns 9 open invoices to `finance.manager@dalseen.sa` and `finance@dalseen.sa`.
- Sprinkles a realistic mix per tenant (contacted with follow-up + note, promised with promise-to-pay date + note, escalated with note).
- Result on `migrate:fresh --seed`: 2 contacted / 2 promised / 2 escalated / 3 not_started.

### Tests

| File | Pins down |
|---|---|
| `CollectionsServiceTest.php` (16 cases) | default status, status transitions audited, no-op same-value, invalid-status rejection, can't manually mark `paid` while invoice unpaid, full payment auto-flips collection→paid, partial payment doesn't, write_off carries high severity, follow_up + promise-to-pay set/clear, assign validates platform staff, assign+unassign audit, add note creates row + audits, empty note rejected, bucket math (5 boundaries), bucket reads invoice due_at, paid invoice → bucket=current. |
| `PlatformCollectionsApiTest.php` (10 cases) | FE can update status + dates + inline note via PATCH; FE cannot assign; FM can assign; FE cannot write off; FM can write off and write_off audit captured; SU cannot view/change collection or queue; add+list notes round trip; empty note rejected via API; outstanding endpoint includes aging breakdown (current/1_30/31_60/61_90/90_plus correctly classified); queue filters by status / assignee=me/unassigned/<id> / bucket. |

**Result: 26 new tests, 74 assertions. Full backend suite: 328 tests, 2310 assertions, 56.66s. All green.**

### Live verification (curl on `php artisan serve`)

| Check | Result |
|---|---|
| `GET /platform/billing/outstanding` for FE | 9 open invoices · 9,623.20 SAR outstanding · aging: current=6 (8016.65), 1_30=3 (1606.55), rest 0. |
| `GET /platform/collections/queue` for FE | 9 invoices with seeded mix: contacted/promised/escalated/not_started, assigned across FE & FM. |
| Round-trip on `INV-2026-00028` | FE PATCH (status=contacted, follow_up=2026-06-15, inline note) → 200 · FE POST standalone note → 200 · GET notes returns both · FM assigns finance@dalseen.sa → 200 (payload shows assignee). |
| Permission matrix | `/collections/queue`: SA/FM/FE 200, OP/SU 403. PATCH /collection (status): SA/FM/FE 200, OP/SU 403. PATCH /collection (write_off): SA/FM 200, FE/OP/SU 403. POST /assign: SA/FM 200, FE/OP/SU 403. |
| Audit | `collection.assign / follow_up / note / promise / status / write_off` all captured. |

### Remaining gaps before P5

- **Auto-overdue sweep job** — invoices past `due_at` aren't yet auto-flipped from `issued` → `overdue` by a scheduled command.
- **Auto-suspend on overdue** — `companies.status` is settable but no middleware blocks tenant requests (P5).
- **Premium-feature route gates** — `Entitlements::hasFeature()` ready since P2; no controller uses it yet (P5).
- **Reminder automation** — `platform.billing.remind` is still an audit-only stub; no actual email/SMS sent.
- **Bank reconciliation import** — payments captured manually; no statement matching.
- **Notifications inside the app** — collection status changes don't push to assigned user's notification feed.
- **Bulk actions** (bulk-assign, bulk-status) — out of scope; one row at a time today.
- **DAL SEEN's own books** — subscription revenue still doesn't post to a GL (P6).

---

## Phase P5 — Subscription Enforcement

Goal: connect subscription state, billing aging, and entitlements to real access control. Suspended tenants are blocked from the app. Overdue tenants lose premium modules but keep core POS. Demo tenant and Internal Mode are unaffected.

Out of scope (deferred): DAL SEEN's own books (P6), payment automation, email reminders.

### Service + command

| File | Purpose |
|---|---|
| `app/Services/Platform/OverdueSweeperService.php` (new) | Idempotent sweep. Finds invoices `status='issued'` with `due_at < today` AND `total - paid_amount > 0.01`, flips to `overdue`, audits `invoice.overdue` (medium). Then reconciles every affected company: `active → overdue` if any open overdue invoice exists; `overdue → active` if none remain. NEVER touches `suspended / cancelled / trial`. Supports `dryRun=true` for previews. |
| `app/Console/Commands/SweepOverdueInvoices.php` (new) | `php artisan billing:sweep-overdue [--dry-run]`. Schedule via `$schedule->command('billing:sweep-overdue')->dailyAt('02:00')` whenever ready. |

### Entitlements payload extension

`Entitlements::for($companyId)` now returns an extended shape:

```json
{
  "plan": {...},
  "status": "active",
  "limits": {...},
  "usage": {...},
  "modules": ["retail","dine","common"],
  "modules_in_plan": ["retail","dine","accounting","common"],
  "premium_features": [...],
  "module_access": {
    "retail":     { "enabled": true,  "reason": null,         "upgrade_required": false, "is_premium": false },
    "dine":       { "enabled": true,  "reason": null,         "upgrade_required": false, "is_premium": false },
    "pay":        { "enabled": false, "reason": "plan_limit", "upgrade_required": true,  "is_premium": true  },
    "accounting": { "enabled": false, "reason": "overdue",    "upgrade_required": false, "is_premium": true  },
    ...
  },
  "addons": [...],
  "is_demo": false,
  "tenant_status":  "overdue",
  "blocked":        false,
  "blocked_reason": null
}
```

Two new constants on `Entitlements`:
- `PREMIUM_MODULES = ['accounting','pay','hr','ecom']`
- `CORE_MODULES = ['retail','dine','common']`

When tenant is `overdue`, `modules` is intersected with `CORE_MODULES` so premium ones disappear from the umbrella module list (and `module_access[*].reason = 'overdue'`). When `suspended/cancelled`, `modules` is empty and `blocked=true` with `blocked_reason` set.

### Middleware

| File | Purpose |
|---|---|
| `app/Http/Middleware/EnsureTenantNotSuspended.php` (new) | Group-level gate on the tenant route group. Throws `TENANT_BLOCKED` (403) with `blocked_reason` field for `suspended/cancelled` tenants. **Bypassed for**: demo tenant (slug=acme), impersonation sessions (so ops can recover), tenants without a subscription row. Does NOT touch `/me` or `/me/entitlements` (those live outside the tenant group, so suspended users can still see why they're blocked). |
| `app/Http/Middleware/EnsureTenantEntitlement.php` (extended) | Now uses the `module_access` map and emits structured codes: `MODULE_NOT_ENTITLED` (plan_limit, upgrade_required=true), `MODULE_LOCKED_OVERDUE` (settle invoices), `MODULE_LOCKED_SUSPENDED`, `MODULE_LOCKED_CANCELLED`. Error envelope carries `reason`, `upgrade_required`, `enabled_modules`, `tenant_status`. |
| `bootstrap/app.php` | New alias `tenant_active` → `EnsureTenantNotSuspended`. |

### Routes wired

The tenant route group now reads:

```php
Route::middleware(['auth:sanctum', 'tenant', 'tenant_active'])->group(function () {
    // /me writes, devices, integrations, marketplace, partner inbox,
    // help, catalog, inventory, suppliers, customers, retail/POS, dine,
    // branches, RBAC, users — all sit under tenant_active so suspended
    // tenants are bounced from any of them.

    Route::middleware('module:pay')->group(function () { /* /pay/* */ });
    Route::middleware('module:accounting')->group(function () { /* /accounting/* */ });
    Route::middleware('module:hr')->group(function () { /* /hr/* */ });
    Route::middleware('module:ecom')->group(function () { /* /ecom/* */ });
});
```

`/retail/*` (POS), `/dine/*`, `/workflows`, `/notifications`, `/devices/*`, `/catalog/*`, `/inventory/*`, etc. are intentionally NOT inside any `module:` sub-group — they're "core POS" and stay reachable for overdue tenants.

### UI changes

- `app/src/modules/owner/EntitlementsCard.jsx` — gains:
  - **Status banner** at the top of the card: red banner for `blocked` (suspended/cancelled), amber banner for `overdue`.
  - **Module access grid**: per-module pill (4-column on desktop) showing each of the 7 modules with a green-checked / amber-locked / rose-locked tone, plus an under-pill reason label ("Upgrade required" / "Settle outstanding" / "Account suspended" / etc.).
  - Demo tenant still shows the "Demo · all modules unlocked" pill so QA isn't confused.

### Audit

Two new action types written by the sweeper:
- `invoice.overdue` (medium severity) — one row per flipped invoice.
- `company.status_change` (medium for overdue, low for back-to-active) — one row per transition.

The `EnsureTenantEntitlement` and `EnsureTenantNotSuspended` middleware do NOT audit; they're rejected by the gate on every request, which would flood the log.

### Tests

| File | Pins down |
|---|---|
| `OverdueSweeperServiceTest.php` (10 cases) | marks issued past-due as overdue; ignores future-due, paid, partially-paid-but-still-owed (correctly flipped); active → overdue when any open overdue exists; rolls back overdue → active when none remain; never touches suspended/cancelled/trial; dry-run writes nothing; audit rows written; idempotent. |
| `SubscriptionEnforcementTest.php` (13 cases) | /me/entitlements module_access reasons (plan_limit/overdue/suspended); suspended tenant can still read /me + /me/entitlements; suspended/cancelled blocked from /workflows with `TENANT_BLOCKED`; overdue blocked from /accounting, /pay, /hr, /ecom with `MODULE_LOCKED_OVERDUE`; overdue keeps /workflows (core POS proxy); active without module gets `MODULE_NOT_ENTITLED` + `upgrade_required=true`; demo tenant bypasses every gate even when forced into `suspended` status with a tight starter plan; tenant without subscription still admitted (P2 backfill safety). |

**Result: 23 new tests, 107 assertions. Full backend suite: 351 tests, 2417 assertions, 59.12s. All green.**

### Live verification (curl on `php artisan serve`)

| Check | Result |
|---|---|
| Demo tenant ACME on enterprise | `is_demo: true`, every module returns 200, `module_access[*].enabled = true`. |
| `al-nakheel` active on enterprise | `/accounting/coa` 200, `/pay/charges` 200; `module_access[accounting].enabled = true`. |
| `al-nakheel` flipped to overdue | `module_access[accounting]: { enabled: false, reason: overdue }`; `/accounting/coa` → 403 `MODULE_LOCKED_OVERDUE`; `/pay/charges` → 403; `/workflows` (core) → 200; entitlements `blocked: false`. |
| `al-nakheel` flipped to suspended | Entitlements `blocked: true, blocked_reason: suspended`; `/me` still 200 (escape hatch); `/workflows` → 403 `TENANT_BLOCKED` with `blocked_reason: suspended`. |
| `php artisan billing:sweep-overdue --dry-run` | Idempotent — second run reports zero changes. Captured `invoice.overdue` and `company.status_change` audit rows. |

### Remaining gaps before P6

- **DAL SEEN's own books** — subscription revenue still doesn't post to any GL. The `company_id`-scoped accounting module exists for tenants but DAL SEEN itself has no books (P6).
- **Premium-feature route gates** — `Entitlements::hasFeature()` is ready, but no production routes gate by individual feature flag (e.g. `advanced_reports`). The module gate is sufficient for P5; per-feature gating is opt-in for P6+.
- **Notification when a tenant is blocked or unblocked** — no in-app notification or email yet.
- **Reminder / invoice email automation** — still stubbed (would naturally pair with P6 ledger work).
- **Cron registration** — `billing:sweep-overdue` exists but `app/Console/Kernel.php` doesn't register a schedule (intentional; the operator decides when to wire it).
- **Tenant write attempts during suspension** — currently blocked with `TENANT_BLOCKED` for ALL routes including PATCH /me, /me/password, /me/branches. This is intentional but worth confirming with finance — they may want users to still be able to change their password while their company is suspended.
- **Bulk un-suspend / un-block flow** — only one tenant at a time today via `POST /platform/tenants/{id}/reactivate`.
- **Per-feature gating** — modules are blocked en bloc; we don't have routes today that need to check, e.g., `multi_branch` or `advanced_reports` separately.

---

## Phase P6 — Platform Accounting (DAL SEEN's own books)

Goal: DAL SEEN gets its own SaaS accounting books — subscription revenue, tenant receivables, VAT payable, payments, refunds, and bad-debt write-offs — completely separate from any merchant tenant's books. Reuses the existing accounting engine (ChartProvisioner, JournalService, account_mappings, journal_entries) but targets a singleton internal company.

### Schema

| File | Change |
|---|---|
| `2026_05_05_250001_add_is_platform_to_companies.php` | Adds `is_platform` boolean (default false, indexed) to `companies`. Exactly one row (slug=`dalseen`) carries `is_platform=true`. |
| `app/Models/Company.php` | New `is_platform` cast + `tenants()` query scope to filter out the platform company from every "tenant list" query. |

### New chart template + mapping keys

- `app/Services/Accounting/Templates/PlatformTemplate.php` — SaaS chart of accounts:
  - **1xxx Assets**: 1010 Operating bank, 1020 Cash, 1110 Tenant receivables.
  - **2xxx Liabilities**: 2210 VAT payable.
  - **3xxx Equity**: 3000 Owner's capital, 3100 Retained earnings.
  - **4xxx Income**: 4100 SaaS subscription revenue, 4900 Subscription refunds (contra-revenue, debit-normal).
  - **5xxx Expense**: 5910 Bad debt expense.
- `ChartTemplateRegistry::PLATFORM = 'platform'` constant + `MAP[PLATFORM]` entry. `ALL` (the public iteration list) is left to the four merchant templates so existing tenant-template tests don't pick up PLATFORM.
- `AccountingTerms` — 7 new mapping keys (`platform.*`) and bilingual labels for them. Never collide with merchant-side keys.

### Services

| File | Purpose |
|---|---|
| `app/Services/Platform/PlatformAccountingService.php` (new) | Singleton resolver. `companyId()` returns the DAL SEEN platform company id; `isProvisioned()` returns false on fresh installs so the poster can short-circuit cleanly. Per-request memoised. |
| `app/Services/Platform/PlatformAccountingPoster.php` (new) | Posts to DAL SEEN's GL via `JournalService::post()`. Four entry points: `postInvoiceIssue`, `postPayment`, `postRefund`, `postWriteOff`. Each is idempotent on `(company_id, source, source_ref)` so re-running the seeder, replaying webhooks, or hitting the same POST twice never double-books. Skips silently when DAL SEEN isn't provisioned. |

### Posting logic

| Event | DR | CR | Source / source_ref |
|---|---|---|---|
| Invoice issued (P3 `BillingService::issue`) | 1110 Tenant receivables (total) | 4100 SaaS revenue (subtotal) + 2210 VAT payable (vat_amount) | `platform_invoice` / `tenant_invoice.id` |
| Payment recorded (P3 `BillingService::recordPayment`) | 1010 Bank (or 1020 Cash for `method=cash`) | 1110 Tenant receivables | `platform_payment` / `tenant_payment.id` |
| Refund (P3 `BillingService::refund`) | 4900 Refund contra (net) + 2210 VAT (proportional reversal) | 1010 Bank (if paid) OR 1110 AR (if unpaid) | `platform_invoice_refund` / `tenant_invoice.id` |
| Write-off (P4 `CollectionsService::updateStatus(written_off)`) | 5910 Bad debt expense | 1110 Tenant receivables | `platform_invoice_writeoff` / `tenant_invoice.id` |

Wired into the existing services so every operational event auto-posts:
- `BillingService::issue` calls `postInvoiceIssue` after the invoice is saved.
- `BillingService::recordPayment` calls `postPayment` inside the same transaction that saved the payment.
- `BillingService::refund` calls `postRefund` after the refund is saved.
- `CollectionsService::updateStatus($invoice, 'written_off')` calls `postWriteOff`.

### Endpoints (added)

All require `platform.accounting.view` (granted to **superadmin + finance-manager only**).

| Method | Path | Returns |
|---|---|---|
| GET | `/platform/accounting/summary` | One-shot KPI tile (subscription revenue, refunds, net, AR, VAT, cash, bad debt). |
| GET | `/platform/accounting/trial-balance` | Same shape as tenant trial balance, balanced by construction. |
| GET | `/platform/accounting/profit-loss` | Income + expenses + net profit. |
| GET | `/platform/accounting/balance-sheet` | Assets / Liabilities / Equity, grouped by type. |
| GET | `/platform/accounting/journals` | Recent journal entries, filterable by `?source=`. |
| GET | `/platform/accounting/gl/{accountCode}` | Account ledger drill with running balance. |

Returns `503 PLATFORM_BOOKS_NOT_PROVISIONED` when DAL SEEN's chart hasn't been seeded.

### Permission added

- `platform.accounting.view` — **SA + FM only**. FE / SU / OP and all tenant users get 403.

### Filtering changes (DAL SEEN never appears as a tenant)

- `Company` model — new `tenants()` query scope (`where('is_platform', false)`).
- `PlatformDashboardController::tenants` — uses `Company::query()->tenants()` for both the list and the KPI rollups (counts, MRR sum).
- `DatabaseSeeder::backfillTenantSubscriptions` — skips `is_platform=true` companies.
- `LoginController::login` — refuses tenant-side login against an `is_platform=true` company (defence-in-depth; the platform company has no tenant users).
- `OverdueSweeperService` — never auto-shifts the platform company's status.

### Frontend

| File | Change |
|---|---|
| `app/src/modules/platform/DalseenBooksPage.jsx` (new) | Full books page: 6-tile KPI strip (subscription revenue, refunds, net, AR, VAT, cash) + bad-debt banner + 4 tabs (P&L, Balance Sheet, Trial Balance, Journals). Period / as-of / source filters per tab. |
| `app/src/modules/platform/platform.api.js` | 6 new endpoints: `paSummary`, `paTrialBalance`, `paProfitLoss`, `paBalanceSheet`, `paJournals`, `paGlAccount`. |
| `app/src/modules/platform/platform.hooks.js` | 6 matching `useQuery` hooks. |
| `app/src/app/App.jsx` | Lazy-mounts `/platform/books` (gated by `platform.accounting.view`). |
| `app/src/app/nav.config.jsx` | New "DAL SEEN's books" entry between Billing and Pay terminals. |

### Pre-existing JournalService fix

`JournalService::trialBalance` queried `ChartAccount` without bypassing the tenant scope. That was harmless for tenant-side reports (the user's own `company_id` matched), but would return `code: null` for every row when called by platform staff (no `company_id`) reading DAL SEEN's books. Fixed by adding `withoutGlobalScope(TenantScope::class)` to the account lookup. The existing `journal_entries` join still constrains to the right company so cross-tenant leakage is impossible.

### Tests

| File | Pins down |
|---|---|
| `PlatformAccountingPosterTest.php` (12 cases) | PlatformTemplate provisions the expected chart; invoice issue posts DR AR / CR Revenue / CR VAT; payment posts DR Bank / CR AR; cash payment hits cash account, not bank; refund with payments credits bank; refund without payments credits AR; write-off posts bad debt and clears AR; idempotent re-issue and re-payment never double-book; poster skips silently when platform not provisioned; tenant books NEVER receive platform postings; zero-total invoice does not post. |
| `PlatformAccountingApiTest.php` (10 cases) | Summary returns top metrics; trial balance is balanced; P&L includes subscription revenue (4100); balance sheet groups by type with AR present; journals endpoint filters by source; GL drill returns running balance; FE/SU/OP and tenant users get 403; 503 with `PLATFORM_BOOKS_NOT_PROVISIONED` when not seeded; DAL SEEN platform company excluded from `/platform/tenants`. |

**Result: 22 new tests, 69 assertions. Full backend suite: 373 tests, 2550 assertions, 65.13s. All green.**

### Live verification (curl on `php artisan serve` after `migrate:fresh --seed`)

| Check | Result |
|---|---|
| `GET /platform/accounting/summary` for FM | subscription_revenue=27,492 / refunds=0 / net=27,492 / outstanding_ar=9,623.20 / vat_payable=4,123.80 / cash_in_bank=21,992.60 / bad_debt=0. |
| `GET /platform/accounting/trial-balance` | totals: debit=53,608.40 = credit=53,608.40, balanced=true. 4 active accounts (1010, 1110, 2210, 4100). |
| `GET /platform/accounting/profit-loss` | income includes 4100 SaaS subscription revenue (27,492); net_profit=27,492. |
| `GET /platform/accounting/journals?source=platform_payment` | 19 payment journals listed correctly. |
| `GET /platform/accounting/gl/1110` | Tenant receivables ledger with 46 lines (27 issues + 19 payments), running balance ends at 9,623.20. |
| Permission gating | SA 200 / FM 200 / FE 403 / OP 403 / SU 403 / Tenant owner 403. |
| Tenant separation | ACME owner GET `/accounting/coa` → 200 (24 accounts in ACME's own retail chart), zero leakage. |
| `/platform/tenants` excludes DAL SEEN | tenant_count=13 (12 demo merchants + ACME), `contains_dalseen=false`. |
| Idempotency | 3 calls to `postInvoiceIssue` for the same invoice → exactly 1 journal entry on DAL SEEN's books. |

### Remaining gaps (post-P6)

- **No platform-side cost tracking yet** — DAL SEEN's expenses (engineering payroll, cloud, marketing) need to be entered as manual journals via the existing manual-JE flow on the merchant accounting module, then we'd point that at the platform company. A dedicated `POST /platform/accounting/journals` endpoint with `platform.accounting.post` permission is the natural next addition.
- **No period close for DAL SEEN's books** — the platform accounting endpoints don't yet enforce a closed-period guard. Adding `accounting_periods` rows for the platform company (with the existing `PeriodCloseGuard`) is straightforward when finance is ready.
- **No VAT return generator for DAL SEEN** — the merchant `vat-returns/generate` endpoint reads from `pos_sales` and `bills`, neither of which apply to DAL SEEN. A platform-specific VAT return that totals `2210 VAT payable` movements is a small follow-up.
- **No invoice PDF / email** — invoices still aren't rendered as PDF or sent via email; this is the same gap from P3.
- **No automated payouts to ZATCA / banking partners** — the bank/cash side of the books is recorded but moving the actual money out requires manual export.
- **Refund proportionality assumes uniform VAT rate** — partial refunds split VAT by the same ratio as the refund, which is correct for KSA's flat 15% but would need rework if any plan ever shipped with mixed VAT rates.
- **No multi-currency** — DAL SEEN's books are SAR-only. Cross-border tenants paying in foreign currency would need an FX layer.
- **No platform-side accounting reports beyond standard ones** — e.g. ARR build-up, MRR cohort, churn rate. These can be derived from the journal data but aren't surfaced on the dashboard yet.

---

### Older P3 verification snapshot (pre-P4)

| Check | Result |
|---|---|
| `GET /platform/plans` for SA | Returns 3 plans, each with `branches/users/devices/modules/premium_features/addons` populated. |
| `GET /platform/tenants/{id}/subscription` for a Growth tenant | Returns current row (`growth, active, monthly, period 2026-05-05 → 2026-06-04`), `limits {5,25,10}`, `usage {0,1,1}`, `modules [retail,dine,accounting,common]`, `premium [multi_branch,advanced_reports]`, `is_demo: false`. |
| `POST /platform/tenants/{id}/plan` upgrade to enterprise | 200; history now has 2 rows: `[enterprise, active, upgrade, present]` + `[growth, active, initial, closed]`. |
| `GET /platform/tenants/{id}/subscription` for SA, FE; `POST /plan` for OP, FM | SA→200, FE→200, OP→403, FM→200. |
| `GET /me/entitlements` for `owner@acme.test` (demo tenant) | `plan: enterprise, is_demo: true, limits {999,999,999}, all 7 modules`. |
| Tight starter tenant (1 branch / 5 users / 2 devices) | `LimitEnforcer` wired into BranchController, UserController, DeviceController, and Platform::issueTerminal — verified by `PlanLimitEnforcementTest`. |

### Remaining gaps (intentionally deferred, picked up by P3+)

- **Recurring invoice generation** — `tenant_invoices` already exists from earlier work, but no monthly job mints them yet (P3).
- **Real payment ingestion** — no `tenant_payments` table; invoices have only a `paid_at` timestamp (P3).
- **Collections workflow** — no follow-up tracking, dunning log, assigned finance employee per overdue invoice (P4).
- **Auto-suspend on overdue** — `setStatus(..., 'overdue')` and `setStatus(..., 'suspended')` work, but no scheduled job triggers them; no middleware blocks tenant access yet (P5).
- **Premium feature enforcement** — `Entitlements::hasFeature()` is ready, but no controllers gate on specific features yet (e.g. "advanced reports" doesn't actually require the feature flag) (P5).
- **`module:` middleware on production routes** — wired into the bootstrap as the `module:` alias and tested via a throwaway test route, but not yet applied to `/accounting/*`, `/pay/*`, `/hr/*`, `/ecom/*` (P5 will add it together with the auto-suspend logic).
- **Add-ons commerce** — the data model + UI editor support add-ons; choosing them on a tenant subscription is an API field but no UI flow lets a tenant buy one yet (P3).
- **DALSEEN's own books** — subscription revenue still doesn't post to a general ledger (P6).

### Live verification (curl on `php artisan serve`)

| Endpoint | SA | FM | FE | SU | OP | Tenant |
|---|---|---|---|---|---|---|
| `GET /platform/tenants` | 200 | 200 | 200 | 200 | 200 | **403** |
| `GET /platform/billing` | 200 | 200 | 200 | **403** | **403** | 403 |
| `POST /platform/billing/{id}/refund` | 200 | 200 | **403** | **403** | **403** | — |
| `POST /platform/tenants/{id}/suspend` | 200 | **403** | **403** | **403** | **403** | — |
| `GET /platform/releases` | 200 | **403** | **403** | **403** | 200 | — |
| `GET /platform/staff` | 200 | **403** | **403** | **403** | **403** | — |

`/me` for `finance.manager@dalseen.sa` returns `mode: platform`, `is_platform_staff: true`, `permissions: [13]` (no tenant.suspend, no impersonate, no staff.manage). Deactivating a user kills their existing tokens (next request → 401) and blocks future logins (`USER_DISABLED`).

### Remaining gaps (intentionally deferred)

These are explicitly out of P1 scope and will be picked up in later phases:
- **Plan limits enforcement** — branches/users/devices/modules. Today plans are still descriptive (P2).
- **Real billing engine** — recurring invoice generation, payment table with method/reference, dunning automation (P3 + P4).
- **Subscription enforcement** — `companies.status='overdue'` still doesn't gate tenant access (P5).
- **DALSEEN's own books** — platform accounting (P6).
- **MFA on platform staff** — currently disabled across the board.
- **Granular non-superadmin staff CRUD** — only superadmin can manage staff today, by design. If finance manager should later be allowed to invite finance employees, that's a separate decision.

---

## Stabilization & demo-readiness pass (post-P6)

Final pre-demo verification across all six personas + end-to-end flows.

### What got fixed during the pass

| Bug | Symptom | Root cause | Fix |
|---|---|---|---|
| Platform invoice posts to wrong company | DAL SEEN's books showed no movement when an invoice was issued via the HTTP API; `JournalEntry` for `platform_invoice` source landed on ACME (the demo tenant) instead. Tinker-driven posts worked correctly. | `JournalService::post()` resolved `$companyId` as `auth()->user()?->company_id ?? $args['company_id']` — for platform staff bound to ACME (the demo seed), the auth value was non-null and shadowed the explicit DAL SEEN id passed by `PlatformAccountingPoster`. | Inverted the resolution order: `$args['company_id'] ?? auth()->user()?->company_id`. Tenant-side posters that don't pass `company_id` keep working unchanged. Pinned with `PlatformAccountingApiTest::test_http_invoice_issue_posts_to_dalseen_not_to_acting_user_company`. |

### 6-role smoke matrix (verified live, fresh seed, dev server)

Representative endpoint × role; cells are HTTP status codes, blank = not applicable.

| Endpoint | SA | FM | FE | SU | OP | Tenant |
|---|---|---|---|---|---|---|
| GET /platform/tenants | 200 | 200 | 200 | 200 | 200 | 403 |
| GET /platform/staff | 200 | 403 | 403 | 403 | 403 | 403 |
| GET /platform/plans | 200 | 200 | 403 | 403 | 403 | 403 |
| GET /platform/billing | 200 | 200 | 200 | 403 | 403 | 403 |
| GET /platform/billing/outstanding | 200 | 200 | 200 | 403 | 403 | 403 |
| GET /platform/collections/queue | 200 | 200 | 200 | 403 | 403 | 403 |
| GET /platform/accounting/summary | 200 | 200 | 403 | 403 | 403 | 403 |
| GET /platform/support | 200 | 403 | 403 | 200 | 200 | 403 |
| GET /platform/releases | 200 | 403 | 403 | 403 | 200 | 403 |
| GET /platform/health | 200 | 403 | 403 | 403 | 200 | 403 |
| GET /platform/signups | 200 | 403 | 403 | 200 | 200 | 403 |
| GET /platform/crm/pipeline | 200 | 200 | 403 | 200 | 403 | 403 |
| POST /platform/tenants/{id}/suspend | 200 | 403 | 403 | 403 | 403 | — |
| POST /platform/billing | 201 | 201 | 403 | 403 | 403 | — |
| POST /platform/billing/{id}/payments | 201 | 201 | 201 | 403 | 403 | — |
| POST /platform/billing/{id}/assign | 200 | 200 | 403 | 403 | 403 | — |

Every cell matches its design intent.

### Platform end-to-end (10 sequential live calls, 1 process)

1. SA creates new tenant → `01kqvxzv50kf79ezpsn9cqpqgj` (slug `demo-stab-…`).
2. FM upgrades plan starter → growth → status `active`, modules `[retail, dine, accounting, common]`.
3. FM creates draft invoice → `INV-2026-00029`, subtotal 599 / VAT 89.85 / total 688.85.
4. FM issues invoice → status `issued`, due_at `2026-05-20`.
5. FE records partial payment 200 SAR (bank_transfer) → outstanding 488.85.
6. FE updates collection: status `promised`, follow-up 2026-06-01, promise-to-pay 2026-06-05, note added.
7. Force overdue (set due_at to 5 days ago) + run `billing:sweep-overdue` → invoice flips to `overdue`, company `active → overdue`.
8. DAL SEEN's books delta verified to the halala:
   - subscription revenue +599 (4100 SaaS subscription revenue)
   - tenant receivables +488.85 (1110)
   - cash in bank +200 (1010)
   - VAT payable +89.85 (2310)
9. Trial balance still balanced: 54,497.25 = 54,497.25.
10. Audit log captured 11 distinct action types: `tenant.create`, `plan.change`, `invoice.create`, `invoice.issue`, `payment.record`, `collection.status`, `collection.note`, `collection.promise`, `collection.follow_up`, `invoice.overdue`, `company.status_change`.

### Tenant end-to-end (separate session, same dev server)

| Step | Result |
|---|---|
| Login `owner@acme.test` workspace=`acme` | OK; mode=`tenant`, modules=8 (incl. premium) |
| `/me/entitlements` | `is_demo=true`, `blocked=false`, all modules enabled |
| Open POS shift | id=`01kqvy2dw69b9jkx54vycn6852`, opening_cash=500 |
| POS sale (Arabic coffee 110 SAR + 15% VAT) | total=126.50, status=completed |
| Trial balance after sale | 3,545 → 3,741.50 (delta +196.50, balanced) |
| P&L | net_profit=815 (was 775) |
| Inventory movements | 8 → 9 rows (POS out-movement created) |
| Dine `/dine/menus`, `/dine/orders` | 200 |
| `/inventory/overview`, `/workflows`, `/notifications` | 200 |
| DAL SEEN's books unchanged after tenant POS sale | rev/AR/bank/vat all identical (perfect separation) |

### Test suite

- `php artisan test` → **374 passed (2,555 assertions)**, 65s.
- New regression: `PlatformAccountingApiTest::test_http_invoice_issue_posts_to_dalseen_not_to_acting_user_company`.

### What is ready for demo

- **Platform cockpit** — Tenants, Plans, Billing, Collections, DAL SEEN's books, Audit, CRM, Health, Releases, Internal Staff. All wired to real APIs, real data, real RBAC.
- **All 5 platform roles** — Super Admin / Finance Manager / Finance Employee / Support Agent / Operations behave exactly as designed; no permission leaks.
- **Complete subscription lifecycle** — create tenant → assign plan → invoice → issue → record payment → work collection → mark overdue → DAL SEEN's GL captures every movement, all auto-posted, all idempotent, all audited.
- **Tenant operational flows** — POS sale, accounting reports, inventory, dashboards, demo data.
- **Full bilingual coverage** — every customer-facing screen has en/ar labels.
- **Test suite** — 374 passing, including 11 platform-accounting tests and the new HTTP-path regression.
- **Demo seed** — single command (`migrate:fresh --seed`) reproduces the entire fixture in ~9s: 13 tenants × 4 templates, 28 invoices, 19 payments, 5 plans, DAL SEEN platform company with 9-account SaaS chart and 46 journals.

### What is risky (works, but be careful)

- **POS sale price overrides** — overriding the line price away from the catalog price can produce an unbalanced journal (`JOURNAL_UNBALANCED`) because COGS still uses the catalog cost. Demo at the natural price; don't ad-lib discounts in front of an audience.
- **Tenant created via `/platform/tenants`** — the owner user gets created with a placeholder password, not `password123`. If you want to log in as the new tenant's owner during a demo, use the **impersonation** button (Super Admin only) rather than trying to type credentials.
- **Idempotency cache** — `IdempotencyDedup` middleware caches non-GET responses for 24h keyed on `(user_id, Idempotency-Key, route)`. The frontend always sends a key, so re-running the same demo flow on the same DB twice in a row may return cached responses. `migrate:fresh --seed` clears it.
- **Period close** — there's a `PeriodCloseGuard` that blocks new posts if a fiscal period is closed. Don't close any period live unless you mean it.
- **Reseed between demos** — collection state, audit log, and DAL SEEN's books carry across demos. Reset before each presenter takes the stage.
- **Browser cache** — if you switched git branches recently, hard refresh + clear `localStorage['dalseen:session:v1']` before demoing, or you'll see stale `/me` payloads.

### What should NOT be demoed yet

- **Outbound email / SMS** — invoice issuance does not actually send anything (`/platform/billing/{id}/remind` returns 200 but is a stub).
- **Stripe / Mada gateway** — no real payment integration; `record payment` is a manual entry by the finance team.
- **Recurring invoice generation** — invoices are created on-demand via `POST /platform/billing`. There is no cron that issues monthly invoices automatically.
- **Tenant-portal billing view** — tenants can't see their own DAL SEEN invoices in the tenant UI today.
- **MFA / SSO for platform staff** — not implemented.
- **Refund-to-card** — refund only updates the invoice + posts a contra journal. There is no money movement back to a payment processor.
- **Platform-accounting writes** — no UI button creates a manual journal in DAL SEEN's books; everything is auto-posted from billing events. Trying to write a manual JE has no UI surface.
- **Branch-level / consolidation reporting** — `journal_lines.branch_id` exists (Phase F1) but no report or filter consumes it yet.
- **Real production environment** — everything runs on SQLite locally. There is no staging/prod target.

### Demo-day artefacts

- `DEMO-FLOW.md` — 4 named, scripted demos (Platform, Merchant, Finance, Support) with sign-in, sequence of clicks, and expected one-liner takeaways.

---

## Platform UX cleanup & real-flow verification (post-stabilization)

A focused two-track pass: (1) sidebar refactor for clarity, (2) audit + fix the signup / onboarding / provisioning chain so a brand-new merchant can actually go from "I clicked sign up" to "I'm signed in and selling".

### Sidebar refactor

Before — 7 groups, 30 items, some duplicated names (e.g. "Plans & Pricing" + "Billing & Plans"), Pay terminals nested under Platform, Workflows + Notifications + Help center cluttering the sidebar:

| Group | Items |
|---|---|
| Platform | Tenants, CRM · CSM, Signups, Onboarding, Plans & Pricing, Billing & Plans, DAL SEEN's books, Pay terminals, Support Desk, System Health, Compliance, Incidents, Releases, Internal staff, Audit Log |
| Hub | Dashboard, Workflows, Notifications |
| Apps | Retail, Dine, Pay, E-commerce |
| Shared | Operations, Merchandise |
| Books | Accounting |
| People | HR |
| Setup | Owner, Marketplace, Integrations, Devices, Roles, Help center |

After — 4 groups, 27 visible items, clear separation between platform commerce, platform ops, tenant business, tenant settings:

| Group | Items |
|---|---|
| **Platform** (internal only) | Tenants, CRM, Onboarding, Plans, Billing & Collections, Platform Accounting, Support Desk, Terminals, Internal staff |
| **Operations** (internal only) | System Health, Compliance, Incidents, Releases, Audit Log |
| **Business** | Dashboard, Retail, Dine, Pay, E-commerce, Inventory / Merchandise, Accounting, HR |
| **Settings** | Devices, Roles, Integrations, Marketplace, Owner |

Renames and removals:
- `CRM · CSM` → `CRM` (the "CSM" was internal jargon)
- `Plans & Pricing` + `Billing & Plans` → `Plans` + `Billing & Collections` (no overlap, clear ownership)
- `DAL SEEN's books` → `Platform Accounting` (the user-facing label)
- `Pay terminals` → `Terminals`
- `Operations` (tenant /ops) + `Merchandise` → single `Inventory / Merchandise` under Business
- `Hub` group (Dashboard + Workflows + Notifications) collapsed; Dashboard moved to top of Business, Workflows + Notifications accessible via topbar bell + URL
- `Help center` removed from sidebar; reachable from the Account menu
- `Signups` removed from sidebar but still routable; the Onboarding page header now carries a `Signup queue →` link so platform staff can pivot in one click

Files changed:
- `app/src/app/nav.config.jsx` — full rewrite, 4 groups, new bilingual labels, new icons where they helped
- `app/src/modules/platform/OnboardingPage.jsx` — added the Signup queue link in the header

The renderer (`AppShell.jsx`) already filters `platformOnly: true` groups for tenant users, so tenant sessions see only Business + Settings (8 + 5 = 13 items at most, role-gated further by `permission`).

### Signup / onboarding / provisioning audit

Audit findings (live HTTP probes, not guesses):

| Surface | Before | Status |
|---|---|---|
| Public `POST /signup` | Frontend `authApi.signup()` called this, backend returned 404 | **Broken contract** |
| Platform `GET /platform/signups` | Real DB read from `tenant_signups` | OK |
| Platform `POST /platform/signups/{id}/promote` | Set status=`converted` only — no Company, no chart, no subscription, no usable owner password | **Mock — name only** |
| Platform `GET /platform/onboarding` | Real DB read from `tenant_onboarding` (7 demo rows) | OK |
| Platform `POST /platform/onboarding/{id}/advance` | Bumps `current_step` field; doesn't trigger any provisioning | **Tracker, not action** |
| Platform `POST /platform/tenants` (manual create) | Created Company + User. Skipped: business_type, chart of accounts, subscription, HQ branch, usable password (random 24-char unguessable hash), branch attachment | **Half-real** |

Concretely: a tenant created via either path (promote OR createTenant) ended up with 0 chart accounts, 0 subscription rows, 0 branches, an owner who could never log in, and no entry in the onboarding workbench. They could not generate an invoice, post a sale, or open a POS shift.

### Fixes

A single shared service so promote and create produce identical results — no second code path to drift:

| File | Purpose |
|---|---|
| `app/Services/Platform/TenantProvisioningService.php` (new) | One transactional `provision()` that creates Company + HQ Branch + Owner User (with usable password) + business-owner role + branch attachment + Chart of Accounts + TenantSubscription (Phase P2 row) + onboarding tracker row. Returns the temp password ONCE so platform staff can pass it to the merchant. Includes a `promoteSignup()` shortcut that maps `signup.vertical` → business_type / systems before calling `provision()`. |
| `app/Http/Controllers/Auth/SignupController.php` (new) | `POST /signup` (public). Validates business name + email + vertical + readiness flags, generates a sequential `S-NNNN` id, scores readiness, persists to `tenant_signups`. Does NOT create a tenant — promotion is a platform decision. |
| `routes/api.php` | Adds public `POST /signup` to the unauthenticated route group |
| `database/migrations/2026_05_05_260001_add_company_id_to_tenant_signups.php` (new) | Adds `tenant_signups.company_id` FK so a converted signup links back to its provisioned tenant; UI can now pivot signup → tenant in one click |
| `app/Models/TenantSignup.php` | Adds `company_id` to fillable + a `company()` relation |
| `app/Http/Controllers/Platform/PlatformController.php::promoteSignup` | Now delegates to `TenantProvisioningService::promoteSignup()`. Validates that the signup isn't already converted/rejected. Returns the full provisioning summary (tenant, owner, branch, subscription, chart, onboarding_id, initial_password) so platform staff have everything in one response. |
| `app/Http/Controllers/Platform/PlatformController.php::createTenant` | Now delegates to `TenantProvisioningService::provision()`. Adds `business_type` to the validation rules. Returns the same enriched response shape as promote. |

### After: live verification

Single fresh boot, all real HTTP, no mocks:

```
Step 1: anonymous POST /signup            → 201 (S-2051, status=pending, score=100)
Step 2: SA promotes signup                → 200
        ✓ tenant id:       01kqvzjg7q6dzs39by37ed3wx6
        ✓ tenant slug:     mocha-lab
        ✓ owner email:     yara@mochalab.test
        ✓ HQ branch:       HQ
        ✓ subscription:    plan=growth status=active
        ✓ chart of accts:  27 accounts + 25 mappings
        ✓ onboarding id:   O-FUFH7C7
        ✓ initial pwd:     welcome-hy097b   (plaintext, returned ONCE)

Step 3: signup row linked back            → signup.company_id set ✓
Step 4: owner logs in with temp password  → 200, branches=1
Step 5: /me/entitlements                  → modules=[retail,dine,accounting,common]
Step 6: POS shift open at HQ              → 200, opening_cash=500
Step 7: trial balance                     → balanced (chart provisioned)
Step 8: DALSEEN-side invoice for new tenant → posts to DAL SEEN's GL ✓
```

A brand-new merchant goes from `POST /signup` → `POS shift open` in 6 real HTTP calls.

### Tests

- 5 new tests in `TenantProvisioningTest`:
  - `test_public_signup_endpoint_persists_a_signup_row`
  - `test_signup_endpoint_validates_required_fields`
  - `test_promote_signup_provisions_a_real_tenant_end_to_end` (every artefact + owner-can-log-in)
  - `test_promoting_an_already_converted_signup_fails_cleanly`
  - `test_create_tenant_endpoint_now_provisions_chart_subscription_branch_and_password`
- Updated 2 existing tests in `PlatformAuditFixesTest` to seed a plan (createTenant now provisions a subscription, which requires the plan to exist).
- Full suite: **379 passed (2,602 assertions)**, 70s.

### Remaining gaps (intentionally out of scope)

- **Email delivery** — the temp password is shown to the platform staff who promoted the signup, but no email is sent to the owner. Promote handlers should optionally trigger a welcome email; deferred until the email infra phase.
- **Onboarding workflow actions** — the onboarding tracker still treats `verify_cr`, `verify_vat`, `data_import`, `team_invite`, `first_invoice`, `go_live` as named milestones. `advance` only bumps the marker forward; it doesn't run KYC, send invites, generate invoices, or flip company status. Platform staff should still drive those side-effects from the dedicated screens (Tenants drawer, Billing page, etc.) and use Onboarding as a tracker.
- **Public signup form** — the `POST /signup` endpoint exists, but the marketing-site landing page that posts to it lives outside this codebase and is not in scope.
- **Internal staff in sidebar** — kept under Platform even though the user's spec didn't list it, because removing it would leave Super Admin with no UI to manage internal users.
- **Signups link** — moved from the sidebar into the Onboarding page header. Per-row "Open in Onboarding" / "View signup" pivots are not yet wired (acceptable, both queues live one click apart).

---

## Forced password change on first login

Small follow-up after the tenant-provisioning work. Newly provisioned merchant owners and newly created platform staff receive a temp password (`welcome-xxxxxx` or 14-char alphanumeric); they can now log in but the entire app is gated until they replace it.

### Backend

| File | Change |
|---|---|
| `database/migrations/2026_05_05_270001_add_must_change_password_to_users.php` (new) | Adds `users.must_change_password` boolean (default false, indexed since the middleware reads it on every authenticated request). |
| `app/Models/User.php` | Adds `must_change_password` to `$fillable` + boolean cast. |
| `app/Services/Platform/TenantProvisioningService.php` | Sets `must_change_password = true` on the freshly-created owner. |
| `app/Http/Controllers/Platform/PlatformStaffController.php` | Sets `must_change_password = true` on `store()` (new staff) and re-arms it on `resetPassword()` (so post-reset the user is forced to change again). |
| `app/Http/Middleware/EnsurePasswordChanged.php` (new) | The gate. No-op when no auth user OR `must_change_password = false`. Otherwise allowlists exactly three paths: `api/v1/me`, `api/v1/me/password`, `api/v1/auth/logout`. Everything else → 403 `PASSWORD_CHANGE_REQUIRED` with `allowed_paths` echoed in the error envelope. |
| `bootstrap/app.php` | Registers the `password_changed` alias and appends the middleware to the global `api` group so every authenticated request is checked once. |
| `app/Http/Controllers/MeController.php::changePassword` | Clears the flag on success — symmetric with where it was set. |
| `app/Http/Controllers/MeController.php::show` | Surfaces `must_change_password` on `/me` so the SPA can route the user to the change-password screen without a second roundtrip. |
| `app/Http/Controllers/Auth/LoginController.php` | Exposes `user.must_change_password` on `/auth/login`, `/platform/auth/login`, and `/auth/mfa` so the SPA picks it up immediately, before the next `/me` arrives. |

### Frontend

| File | Change |
|---|---|
| `app/src/modules/auth/auth.session.js` | `toSessionPayload` now extracts `mustChangePassword` from both login and `/me` shapes. |
| `app/src/core/session/sessionStore.js` | Adds `mustChangePassword` to default session shape + `partialize` (so it persists across reloads). |
| `app/src/app/App.jsx` | Hydrates `mustChangePassword` into the session store on every `/me` response. |
| `app/src/modules/auth/ProtectedRoute.jsx` | The SPA mirror of the backend gate. When `mustChangePassword` is true, redirects every protected route to `/account/password` (allowlist: `/account` + `/account/password` so the change-password page itself is reachable). |
| `app/src/modules/auth/ChangePasswordPage.jsx` | Clears the SPA flag on success so the user can navigate immediately, and renders a small "Action required — temporary password" banner explaining why they're stuck on this screen. |

### Live verification (real HTTP)

```
Step 1: signup + SA promote       → tenant + initial_pwd=welcome-xxxxxx
Step 2: owner logs in             → 200, response carries must_change_password=true
Step 3: gated routes              → 403 PASSWORD_CHANGE_REQUIRED everywhere except /me
   GET  /me                       → 200    (allowed)
   GET  /me/branches              → 403    (gated)
   GET  /me/entitlements          → 403    (gated)
   GET  /accounting/.../trial-balance → 403  (gated)
   POST /pos/shifts/open          → 403    (gated)
Step 4: POST /me/password         → 204
Step 5: gate lifts                → /me must_change_password=false
   GET  /me/branches              → 200
   GET  /me/entitlements          → 200
Step 6: re-login with new pwd     → 200, must_change_password stays false
Step 7: pre-existing owner@acme   → unaffected, all routes 200
```

Error envelope on a blocked route:

```json
{
  "error": {
    "code": "PASSWORD_CHANGE_REQUIRED",
    "message_en": "You must change your temporary password before continuing.",
    "message_ar": "يجب تغيير كلمة المرور المؤقتة قبل المتابعة.",
    "fields": {
      "allowed_paths": ["api/v1/me", "api/v1/me/password", "api/v1/auth/logout"]
    }
  }
}
```

### Tests

8 new tests in `ForcedPasswordChangeTest`:
- `login response carries must change password true for temp password owners`
- `me endpoint is reachable while must change password is true`
- `logout is reachable while must change password is true`
- `change password endpoint is reachable while flag is true`
- `other routes are blocked while flag is true` (both tenant + platform routes)
- `successful password change clears the flag and unblocks other routes`
- `existing users without the flag are not affected` (regression guard for the 387 prior passing tests)
- `temp password paths set the flag` (TenantProvisioningService + PlatformStaffController.store + .resetPassword)

Plus 1 small fix in `PlatformStaffControllerTest::cannot_deactivate_last_superadmin`: the existing test creates a fresh staff user via the API (which now correctly sets the flag) and acts as them. Cleared `must_change_password` explicitly so the test isolates the safety rail it's actually about.

Full suite: **387 passed (2,628 assertions)**, 66s.

### Notes & gaps

- **Email** is still not sent. Temp passwords are returned exactly once in the create / promote / reset response and shown to the platform staff member who took the action. Out-of-band hand-off until the email infra phase.
- **Strength rules** stay at the existing `Password::min(8)` + `confirmed`. We did not raise this in this pass.
- **Token rotation on password change** is not part of this pass — the user keeps their existing token (just unblocks the rest of the app). Reset-on-password-change can be a separate, larger session-management pass.
- **MFA** for platform staff is still off across the board (carried over from P1 — same as before this pass).

---

## Phase 1 — Tax invoice (PDF)

Saudi-style tax invoices generated as real PDFs, on demand, from stored numbers.

### Library

`barryvdh/laravel-dompdf` ^3.1 (DomPDF). Pure PHP, no native binaries. Default font is `DejaVu Sans` (DomPDF bundles it) — covers Latin and the Arabic glyphs we use in the bilingual headers without extra setup.

### Files

| File | Purpose |
|---|---|
| `composer.json` + `composer.lock` | Adds `barryvdh/laravel-dompdf:^3.1`. |
| `app/Services/Invoices/InvoicePdfService.php` *(new)* | One service rendering BOTH platform invoices (`renderTenantInvoice` / `previewTenantInvoiceHtml`) and POS sales (`renderPosSale` / `previewPosSaleHtml`). Reads stored values only — no recalculation. Builds a Saudi ZATCA Phase 1 simplified e-invoice TLV payload (seller, VAT, timestamp, total, VAT total) and base64-encodes it for the QR placeholder. |
| `resources/views/invoices/tax-invoice.blade.php` *(new)* | Single bilingual blade template shared by platform invoices and POS receipts. Section blocks: header (title + invoice meta + status badge), parties (issuer / customer with VAT, CR, address), line items table, totals box (subtotal, VAT with stored rate, total, plus paid + outstanding for platform invoices), QR placeholder + ZATCA caption, footer. CSS lives inline in `<style>`. |
| `app/Http/Controllers/Platform/PlatformController.php::billingPdf` | New controller method — wraps `InvoicePdfService::renderTenantInvoice()` in a 200 response with `Content-Type: application/pdf`, inline disposition by default, `attachment` when `?download=1`, plus `X-Invoice-Id`, `X-Invoice-Total`, `Cache-Control: private, no-store` headers. |
| `app/Http/Controllers/Pos/PosController.php::receiptPdf` | Same pattern for POS sales. |
| `routes/api.php` | `GET /platform/billing/{id}/pdf` (gated by `platform.billing.view`) and `GET /pos/sales/{sale}/pdf` (tenant-scoped, same gate as the JSON receipt). |

### Endpoint contract

```http
GET /api/v1/platform/billing/{id}/pdf
GET /api/v1/platform/billing/{id}/pdf?download=1     # forces save dialog
GET /api/v1/pos/sales/{sale}/pdf
GET /api/v1/pos/sales/{sale}/pdf?download=1

→ 200 application/pdf
   Content-Disposition: inline; filename="INV-2026-00001.pdf"
   X-Invoice-Id:    INV-2026-00001
   X-Invoice-Total: 688.85
   Cache-Control:   private, no-store

→ 401 if no auth
→ 403 if missing platform.billing.view (platform PDF) or wrong tenant (POS PDF)
→ 404 if id not found
```

### What's on the page

A typical platform invoice (INV-2026-00001, 688.85 SAR, 0 paid / 688.85 outstanding):

- Header: `Tax Invoice` / `فاتورة ضريبية` title, issuer wordmark, invoice number, issued + due dates, status pill.
- Two parties side by side: ISSUED BY (DAL SEEN, VAT, address in Riyadh) and BILLED TO (tenant legal name EN/AR, tenant VAT, tenant address from `companies.settings.address` if set).
- Line items table with bilingual column headers — single subscription line per invoice for now.
- Totals box: `Subtotal · 599.00 SAR`, `VAT (15%) · 89.85 SAR`, `Total · 688.85 SAR` in a black band, then `Paid · 0.00 SAR` and `Outstanding · 688.85 SAR` in colour.
- QR placeholder (striped square) + the actual ZATCA Phase 1 TLV payload base64-encoded underneath as a caption.
- Footer with English + Arabic legal disclaimer.

Sample PDFs written for inspection at:
- `backend/storage/app/sample-invoices/INV-2026-00001.pdf` (43 KB)
- `/tmp/dl-platform-invoice.pdf` (live HTTP roundtrip)

### Hard rules — verified by test

| Rule | Test |
|---|---|
| Endpoint returns valid PDF binary with correct headers | `endpoint returns pdf with correct headers` |
| `?download=1` flips disposition to attachment | `download query flips disposition` |
| Stored totals only — NO recalculation, even for funky stored numbers | `pdf uses stored totals no recalculation` (passes a stored 5% VAT row of 47.50; the rendered HTML must show 47.50 and 1,047.50, NOT 150.00 / 1,150.00) |
| All ZATCA-required fields are on the page (title, invoice #, dates, seller name + VAT, buyer name + VAT, subtotal, VAT, total, currency) | `pdf includes required tax invoice fields` |
| Status badges + paid/outstanding lines + QR caption render | `html preview carries qr and status` |
| 404 for missing invoice | `pdf endpoint is 404 for missing invoice` |
| 401 anonymous, 403 wrong-permission staff, 200 finance-employee | `pdf endpoint requires billing view permission` |
| Service can be called outside HTTP (for future "email PDF" jobs) | `invoice pdf service renders binary directly` |

### Live verification

```
Test invoice: INV-2026-00001
  GET /platform/billing/INV-2026-00001/pdf
    FM (billing.view)            → 200  application/pdf  43,021 bytes
    Headers: Content-Type=application/pdf
             Content-Disposition=inline; filename="INV-2026-00001.pdf"
             X-Invoice-Id=INV-2026-00001  X-Invoice-Total=688.85
             Cache-Control=private, no-store
    With ?download=1             → Content-Disposition=attachment; filename="INV-2026-00001.pdf"
    Support agent (no perm)      → 403
    Anonymous                    → 401
    Missing invoice              → 404
```

Full backend suite: **395 passed (2,663 assertions)**, ~70s. (387 → +8 PDF tests.)

### Remaining gaps (intentionally out of scope)

- **Real QR encoding** — the QR area is a striped placeholder with the TLV payload printed beneath. Swapping in a real QR is a one-line addition (e.g. `endroid/qr-code` + an `<img src="data:image/png;base64,…">` in the blade) when we want the QR scannable.
- **Logo asset** — no logo is embedded; the header uses a typographic "DAL SEEN" wordmark.
- **ZATCA Phase 2 cryptographic signing** — not implemented. Phase 1 simplified e-invoice format only.
- **PDF caching** — every download re-renders. Acceptable for current volume; if/when needed, store in `media` and serve via `/media/{id}` with content-addressed hash.
- **Itemised lines on platform invoices** — single subscription row per invoice. Add-ons / proration would need to break this out.
- **Extra ZATCA fields on companies** — `companies.settings.address` and `companies.settings.cr_no` are read for the party block but not yet editable in the platform UI; they fall back to sensible defaults when missing. A future "Tenant settings → tax profile" form is the natural place to surface them.

---

## Phase 2 — Support Desk with chat

Real chat-style support tickets. Tenants open tickets and reply on a dedicated page; platform staff triage, assign, change status, post public replies, and post staff-only internal notes from a single inbox. No WebSockets, no notifications, no automation.

### Schema

| File | Change |
|---|---|
| `database/migrations/2026_05_05_280001_extend_support_tickets_and_create_messages.php` *(new)* | Extends `support_tickets` with `ticket_number` (TKT-YYYY-NNNNN), `category` enum, `body`, `requester_user_id` FK, `assigned_to_user_id` FK, `last_message_at`, `closed_at`. Migrates legacy `waiting` → `waiting_customer`. Backfills `ticket_number` from existing `id` for seeded rows. Creates `support_ticket_messages` with `(ticket_id, sender_user_id?, sender_type, is_internal, body, timestamps)`. |
| `app/Models/SupportTicket.php` *(new)* | Eloquent model. Constants `CATEGORIES`, `STATUSES`, `PRIORITIES`. Relations: `company()`, `requesterUser()`, `assignedTo()`, `messages()` (oldest first), `publicMessages()` (`is_internal=false`). |
| `app/Models/SupportTicketMessage.php` *(new)* | Sender types: `platform_staff`, `tenant_user`, `system`. ULID PK. `is_internal` boolean cast. |
| `database/seeders/DatabaseSeeder.php::seedSupport` | Seeds 8 tickets across all 6 categories (technical / billing / onboarding / accounting / pay / other) and the new statuses. Two of them have a multi-message thread so the chat UI has something to render on first boot. |

`status` enum is now: `open · in_progress · waiting_customer · resolved · closed`
`category` enum is: `technical · billing · onboarding · accounting · pay · other`

### Service

`app/Services/Support/SupportService.php` *(new)* — single home for the lifecycle. Handles:

- `nextTicketNumber()` — TKT-YYYY-NNNNN with per-year sequence + `lockForUpdate`
- `createForTenant()` — builds the ticket + persists the original body as the first `tenant_user` message (so the chat reads naturally from the top)
- `addMessage()` — derives `sender_type` from the user, defends the `is_internal` flag (only platform staff can set it), enforces tenant-can-only-reply-to-their-own-ticket, auto-transitions `open → in_progress` when a staff member first replies and `waiting_customer → in_progress` when the customer replies, bumps `last_message_at`
- `assign()` — validates assignee is platform staff, auto-posts a `system` message ("Ticket assigned to ...") so both sides see the change in the timeline, audits
- `changeStatus()` — validates the enum, sets/clears `closed_at`, auto-posts a `system` message ("Status changed: open → resolved.")
- Audit log calls for every state-changing action via `Audit::record()`

### APIs

```http
# Platform side (DAL SEEN inbox)
GET  /platform/support                       — list with KPIs + filters (status, priority, category, assigned_to, q)
GET  /platform/support/agents                — picker source for assignee
GET  /platform/support/{id}                  — ticket detail
GET  /platform/support/{id}/messages         — full thread incl. internal notes
POST /platform/support/{id}/messages         — post public reply
POST /platform/support/{id}/notes            — post internal note (staff-only)
POST /platform/support/{id}/assign           — body { user_id|null }, auto-posts system message
POST /platform/support/{id}/status           — body { status }, auto-posts system message + sets closed_at

# Tenant side (auth + tenant + tenant_active)
GET  /support/tickets                        — own tenant's tickets, KPI strip
POST /support/tickets                        — body { subject, category, priority?, body }
GET  /support/tickets/{id}                   — detail (own tenant)
GET  /support/tickets/{id}/messages          — public messages only (internal notes filtered)
POST /support/tickets/{id}/messages          — reply
```

Legacy aliases `/support/{id}/resolve` and `/support/{id}/reply` are kept for back-compat (still gated as before).

### UI

| File | Purpose |
|---|---|
| `app/src/modules/platform/SupportPage.jsx` | Replaced. New chat-aware inbox: 6 KPIs (Open / In progress / Waiting customer / Urgent / Unassigned / Mine), filter row (status, priority, category, assignment), table that sorts by last reply. Polls every 30s. |
| `app/src/modules/platform/_shared/TicketDetailDrawer.jsx` | Replaced. Chat thread with bubbles (system messages render as a centered pill, internal notes get an amber tint, staff right-aligned, customer left-aligned), composer with an "Internal note" toggle (gated by `platform.support.internal_note`), inline status + assignee dropdowns gated by `platform.support.status` / `platform.support.assign`. Auto-scrolls to the newest message. Polls every 5s. |
| `app/src/modules/support/SupportPage.jsx` *(new)* | Tenant-side page. List of own tickets, KPI strip, status filter pills, "+ New ticket" modal (subject / category / priority / body), chat drawer with the same bubble pattern but no internal-note toggle. Polls every 30s / 5s. |
| `app/src/modules/support/support.api.js` *(new)* | Tenant API client. |
| `app/src/modules/support/support.hooks.js` *(new)* | Tenant React-Query hooks with the same polling cadence as platform side. |
| `app/src/modules/platform/platform.api.js` | New endpoints: `listMessages`, `postMessage`, `postInternalNote`, `changeTicketStatus`, `supportAgents`. Legacy `replyTicket` / `resolveTicket` kept as aliases. |
| `app/src/modules/platform/platform.hooks.js` | New hooks: `useTicketMessages`, `useSupportAgents`, `usePostMessage`, `usePostInternalNote`, `useChangeTicketStatus`. |
| `app/src/app/App.jsx` | Lazy-loads `TenantSupportPage`, mounts at `/support`. |
| `app/src/app/nav.config.jsx` | Adds a single `Support` entry to the Business group (tenant-side only) — no other sidebar changes. |

### Permission matrix

| Permission | SA | Finance Mgr | Finance Emp | Support Agent | Operations |
|---|:-:|:-:|:-:|:-:|:-:|
| `platform.support.view` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `platform.support.assign` | ✓ |  |  | ✓ | ✓ |
| `platform.support.status` | ✓ |  |  | ✓ |  |
| `platform.support.reply` | ✓ | ✓¹ | ✓¹ | ✓ |  |
| `platform.support.internal_note` | ✓ | ✓ | ✓ | ✓ | ✓ |

¹ Finance roles can only reply on `category=billing` tickets — the controller enforces this with a `FORBIDDEN_CATEGORY` 403 envelope. Granting them `support.reply` alone wouldn't let them touch a technical ticket.

Tenant side: any authenticated user can read + create + reply on their own tenant's tickets. No granular permission — the BelongsToTenant scope provides isolation. Suspended tenants are blocked by the existing `tenant_active` middleware before they reach the controller.

### Audit

Every state change writes to `audit_log` via `Audit::record()`:

- `support.ticket.create`        (when a ticket is opened)
- `support.ticket.message`       (public reply, either side)
- `support.ticket.internal_note` (staff-only note)
- `support.ticket.assign`        (with before/after assignee)
- `support.ticket.status`        (with before/after status)

The `system` messages auto-posted by assign/status are NOT separately audited — the action that triggered them already is.

### Tests

13 new tests in `SupportDeskTest`, all passing. Pin the contract end-to-end:

- creation persists the row + the first message (sender_type=tenant_user)
- ticket numbers are sequential per year (TKT-YYYY-00001, 00002, …)
- ticket creation is audited
- tenant cannot read another tenant's ticket / messages / post (cross-tenant 404)
- platform reply appears on tenant timeline + auto-flips status open→in_progress
- internal notes are visible to staff but completely hidden from tenant
- tenant cannot post internal notes (even if they hit the platform path → 403)
- support agent can change status + reply
- finance roles can reply ONLY on billing-category tickets (FORBIDDEN_CATEGORY)
- operations can assign but not reply or change status
- assigning to a non-platform-staff user fails (INVALID_ASSIGNEE)
- full audit trail captures all 5 action types
- closing a ticket sets `closed_at` + posts a system "Status changed" message

Plus the updated test scaffold (`tests/TestCase.php`) gains the new permissions in the inventory and applies them to the right roles via `ensurePlatformRoleMatrix()` so other tests don't accidentally lose the new gates.

Full backend suite: **408 passed (2,737 assertions)**, ~71s. (395 → +13 support tests.)

### Live verification

Real HTTP roundtrip on a fresh seed:

```
Step 1  tenant owner POST /support/tickets       → TKT-2026-00001 (technical, high)
Step 2  tenant GET /support/tickets               → total=1 open=1 newest=TKT-2026-00001
Step 3  Operations POST .../assign user_id=...    → assigned to Lana Al-Sharif, status: in_progress
Step 4  Operations POST .../messages              → 403 (no .reply)
        Operations POST .../status                → 403 (no .status)
Step 5  Support Agent POST .../messages           → public reply (sender=platform_staff, internal=false)
        Support Agent POST .../notes              → internal note (sender=platform_staff, internal=true)
Step 6  Platform GET .../messages                 → 4 messages (incl. system + internal note)
Step 7  Tenant GET /support/tickets/{id}/messages → 3 messages (internal note correctly hidden)
Step 8  Finance POST .../messages on billing TKT  → 201
        Finance POST .../messages on technical    → 403 FORBIDDEN_CATEGORY
Step 9  Tenant GET other-tenant ticket            → 404 (cross-tenant block)
Step 10 Support Agent POST .../status closed      → status=closed, closed_at set
Step 11 audit_log support.ticket.* for the ticket → create, assign, message, internal_note, status
```

### Remaining gaps (intentionally out of scope)

- **Notifications** — no toast / email / push when a new message arrives. Polling (30s inbox / 5s open ticket) is the user-facing latency. Real-time push lands when we add a notification phase.
- **Email channel** — tickets can only be opened in-app today. No inbound email parser, no outbound notification email.
- **Attachments** — `support_ticket_messages.body` is text only. Files would reuse the existing `media` upload but would need a small JOIN table or a `media_id` on the message.
- **Macros / canned replies** — staff still type each reply manually.
- **SLA timer + breach badge** — `sla` column exists (free-text label), but there is no countdown or first-response/resolution clock.
- **CSAT survey** — closing a ticket auto-posts a system message, but does not prompt for a rating.
- **Ticket merging / splitting** — single-thread, single-tenant only.
- **Linking to other resources** — no "open this ticket from the invoice drawer" / "from the tenant detail" pivots yet (tickets are reachable from the inbox only).

---

## Public Website (marketing + CMS)

Anonymous marketing routes in the SPA: `/` (home), `/pricing`, `/signup`, `/contact`; auth stays at `/login`; the product shell mounts at **`/app/*`**. CTAs wire to `POST /api/v1/signup`, `POST /api/v1/public/contact`, and plan-prefilled URLs (`?plan=id`) on signup and contact.

Super Admin edits copy and public plan visibility in **Platform → Public Website** (`/app/platform/website`), backed by **`platform.public_site.manage`** only.

### Schema (existing + new)

| Piece | Purpose |
|---|---|
| `public_site_settings` (key, value JSON, `updated_by_user_id`) | Sections: homepage, pricing, contact, footer, misc |
| `public_plan_settings` | Per-plan: `is_public`, `public_order`, `is_featured`, bilingual marketing fields + CTAs |
| `tenant_signups.requested_plan_id` *(new migration `2026_05_05_300001_...`)* | Optional plan chosen from pricing; `tenant_signups.notes` *(290001)* holds contact bodies + `[Plan interest: …]` |

### Public APIs (no auth)

- `GET /api/v1/public/site` → merged `{ homepage, pricing, contact, footer, misc }`
- `GET /api/v1/public/plans` → `{ items: [...] }` (sanitized cards: no `modules`, `addons`, `tenants_count`)
- `POST /api/v1/public/contact` → optional `plan_interest`; message stored with plan line in notes

### Platform CMS APIs (Super Admin)

- `GET /api/v1/platform/public-site`
- `PATCH /api/v1/platform/public-site` body `{ section, value }`
- `PATCH /api/v1/platform/public-plans` body `{ plans: [{ id, ... }] }`

### Frontend fixes & additions

- **`realRequest` unwrap**: public pages now read `useQuery.data` correctly (removed erroneous `.data.data`).
- **`app/src/core/auth/landing.js`**: dashboard roles land on `/app` (was `/`, which conflicted with the public homepage).
- **`app/src/modules/platform/PublicWebsitePage.jsx`**: CMS tabs (Homepage, Pricing copy, Contact, Footer, Public plans table).
- **`platform.api.js` / `platform.hooks.js`**: `publicSite`, `patchPublicSite`, `patchPublicPlans`.
- Pricing cards: secondary **Contact sales** link → `/contact?plan=…`; homepage teaser links similarly.

### Signup payload

Optional `plan_id` on `POST /api/v1/signup` — must exist in `subscription_plans`; stored as `requested_plan_id`.

### Tests

`tests/Feature/PublicWebsiteTest.php` — **10** cases (no-auth reads, RBAC on CMS, login, bilingual patch, signup + plan, contact plan interest, public plan hiding/order/featured). **Full suite: 418 passed.**

### Remaining gaps

- No WYSIWYG or media library — JSON textareas for module / “why” arrays on the CMS homepage tab.
- Public plans still expose numeric **limits** (branches/users/devices) as marketing badges; internal module flags are not leaked.
- No automated E2E in CI for SPA routes `/` vs `/app` (covered manually + API tests).

---

## Session — Onboarding flow + opaque slugs + signup status (10 commits)

A single working session that landed in 10 commits on `main` (`05e1c49 → 99dd8b5`). Three loose threads, one common backbone:

1. **Permission + tenant-isolation hygiene** (commits 1–2) — fix two silent gates the wired Accounting module was leaking on, and patch a cross-tenant write bug in the POS shift open path.
2. **Manual onboarding hardening** (commits 3–5) — surface the temp-password the platform admin needs to share, stop lying in UX copy about email/SMS we don't send, and let merchants choose their own password at signup so no temp is generated at all.
3. **Activation codes + opaque slugs + status lookup** (commits 6–10) — issue one-shot codes that grant the `trial_unlimited` plan when redeemed, replace the legacy slug scheme with an opaque generator, and bolt a public throttled status page on the front so merchants can discover their auto-generated slug.

Backend test suite at the end of session: **565 tests / 4,481 assertions, all green** (was ~494 at session start). Endpoint diff: **0 missing routes** across 306 unique live endpoints. Frontend lints + Vite build clean.

**Parked at the end of session, not pushed:** the Tier-1 `perm-diff.mjs` guard script + `defer:` flags in `permissions.js` + matching `package.json` script entry. Held back pending tenant ↔ platform separation refactor.

### Commit ledger

#### 1. `05e1c49` — Seed accounting tab/step-up perms, fix two `stepUpFor` mismatches

**What:** Eight accounting permission keys the frontend already gates on were never seeded, so every non-owner role saw the wired Accounting tabs disappear in real-mode while they remained visible in mock-mode.

**Files (3):**
- `backend/database/seeders/DatabaseSeeder.php` — added `bills.view`, `coa.view`, `bank.view`, `vat.view`, `vat.file`, `zatca.clear`, `reports.view`, `journal.post` to permission seeder; assigned to `manager` (reads), `accountant` (full books), `auditor` (reads, no `zatca.clear`, no `journal.post`); also granted accountant `coa.manage`, `settings.view`, `settings.manage` (already-seeded but never assigned).
- `app/src/core/auth/permissions.js` — flagged `accounting.journal.post` with `stepUp: true`.
- `app/src/modules/accounting/accounting.api.js` — retargeted two `stepUpFor` mappings: `createJE` from `period.close` → `journal.post`, `fileVat` from `zatca.enrol` → `vat.file`.

**Tests:** none new (no behaviour change). Permission count moved 124 → 132. `verify:endpoints` + `verify:undefined` clean.

**Decisions:**
- Auditor explicitly does **not** get `zatca.clear` or `journal.post`, only the five reads.
- No `senior-accountant` split — `journal.post` stays on accountant only.
- Three keys (`invoices.create`, `journal.reverse`, `bank.reconcile`) were intentionally NOT seeded yet — they have no UI consumers.

**Follow-up captured:** server-side permission middleware on the accounting routes. Logged as separate scope, not started.

---

#### 2. `9087cd3` — `fix(pos)`: tenant-scope `branch_id` validation on `openShift`

**What:** `PosController::openShift` validated `branch_id` with `Rule::exists('branches', 'id')` — no `company_id` filter — so a tenant A cashier could open a POS shift against tenant B's `branch_id`. The `BelongsToTenant` trait then auto-filled `company_id` to tenant A on insert, producing a cross-attributed shift (tenant A's `company_id`, tenant B's `branch_id`). Any subsequent sale rows would inherit the same drift.

**Files (2):**
- `backend/app/Http/Controllers/Pos/PosController.php` — added `->where('company_id', $companyId)` to the exists rule, matching the pattern already in `AdjustmentController::store`.
- `backend/tests/Feature/PosTest.php` — regression test `test_open_shift_rejects_cross_tenant_branch_id`.

**Tests:** **+1**. Asserts a tenant A cashier passing tenant B's `branch_id` gets `422 VALIDATION_FAILED` with a `branch_id` field error, and that **no `PosShift` row is created in either tenant** (`withoutGlobalScopes()->count() === 0`).

**Decisions:** the audit was paused mid-flight to ship this hotfix as a single dedicated commit before the larger branch-scoping refactor.

---

#### 3. `0c14a1e` — `fix(platform/signups)`: show one-time temp-password dialog after promote

**What:** Backend `promoteSignup` returned `initial_password` so platform staff could hand it to the new tenant owner. The Signups page silently discarded it — `onSuccess` only closed the drawer, leaving the admin no way to see the temp password short of inspecting the network tab.

**Files (2):**
- `app/src/modules/platform/SignupsPage.jsx` — captured the response in `onSuccess`, store `tempPassword` state, render the new dialog overlay.
- `app/src/modules/platform/_shared/InitialPasswordDialog.jsx` *(new)* — monospace code block, Copy button with feedback, "shown only once" warning, first-login change reminder, tenant context shown above. Mirrors `StaffPage`'s `TempPasswordModal` pattern.

**Tests:** none (pure frontend overlay, no behaviour change at the API).

**Decisions:**
- Designed as the **fallback** display: only shown when the backend generated a temp password because the merchant didn't supply one at signup time. Future flow where merchants set their own password (commit 5) uses a different confirmation dialog.

---

#### 4. `5da5f81` — `fix(platform)`: correct misleading "sent to applicant" copy on signup reject + onboarding nudge

**What:** The signup reject modal claimed "this is sent to the applicant" and the Onboarding page offered an email/SMS/WhatsApp channel picker — but neither path actually delivers anything. Backend `rejectSignup` only writes an audit row; `nudgeOnboarding` only writes an audit row. The UX implied delivery the system doesn't do.

**Files (2):**
- `app/src/modules/platform/SignupsPage.jsx` — rephrased reject modal: "Reason is logged to the audit log. The applicant is NOT notified automatically — reach out via WhatsApp or phone separately."
- `app/src/modules/platform/OnboardingPage.jsx` — dropped the email/SMS/WhatsApp channel picker, renamed the modal + row button to "Mark as contacted", added inline note: "This action only records the touchpoint to the audit log. The system does NOT send anything — delivery is manual." Dropped the now-unused channel state. Mutation body no longer carries `channel` (backend already accepts nullable).

**Tests:** none (copy + UI only).

**Decisions:** correctness over momentum — the channel picker was decorative and actively misleading. Removing it beats keeping it for "future email integration".

---

#### 5. `1a2b72c` — `feat(signup)`: merchant chooses own password during public signup

**What:** The public signup form now requires `password` + `password_confirmation`. The merchant's password is bcrypted on the signup row and used directly when platform staff promotes the signup — no temp password generated, no first-login change forced. Legacy / admin-created signups (no `password_hash` on the row) keep working with the existing temp-password fallback path.

**Files (12):**
- Backend: new migration `2026_05_05_380001_add_password_hash_to_tenant_signups.php` (`password_hash` nullable + `merchant_set_password` boolean), `TenantSignup.php` model fillable + cast + hide `password_hash`, `SignupController.php` validates `Password::min(8) + confirmed`, `TenantProvisioningService.php` accepts `initial_password_hash` parameter that takes precedence over `initial_password` (writes bcrypt directly, sets `must_change_password=false`, returns `merchant_set_own_password=true` + `initial_password=null`), `PlatformController.php` carries `merchant_set_own_password` in promote response.
- Frontend: `PublicSignup.jsx` adds password + confirmation under Section 02 with inline strength feedback (≥8 chars, mismatch detection) and submit blocking until both pass; new `MerchantPasswordSetDialog.jsx` confirms the merchant's password is set (no temp to share); `SignupsPage.jsx` `promote.onSuccess` branches on `merchant_set_own_password` to show the right dialog.

**Tests:** **+4** new in `TenantProvisioningTest` (signup persists hash + flag; weak password rejected; mismatched confirmation rejected; promote uses merchant password and skips `must_change_password` — verified by a real `/me` call after login). Three existing tests in `AdversarialQATest`, `WholeSystemE2ETest`, `PublicWebsiteTest` were updated to include the now-required password fields. Full Feature suite: **537 tests / 4,293 assertions**.

**Decisions:**
- Frontend strength check stays minimal (length + match). Server is the source of truth.
- Pre-hashed bcrypt is written directly via the User model's `password => 'hashed'` cast, which is documented to skip re-hashing for already-bcrypted values.

---

#### 6. `c556186` — `feat(platform)`: activation codes — schema, controller, signup/promote integration, `trial_unlimited` plan

**What:** Issue/list/revoke one-shot strings that, when redeemed on the public signup form, grant the resulting tenant the `trial_unlimited` plan (all modules + all premium features, +90-day window) on promotion. Superadmin only.

**Schema:**
- `activation_codes` table (ULID id, unique `code`, `notes`, `created_by_user_id`, two-phase used-by linkage `used_by_signup_id` + `used_by_company_id` + `used_at`, `revoked_at` + `revoked_by_user_id`). Status is **derived** from `revoked_at` / `used_at` — no enum column.
- `tenant_signups` gains `activation_code_attempted` (string, audit trail for any attempted code), `activation_code_valid` (boolean), `used_activation_code_id` (FK).

**Two-phase code consumption:**
1. **Signup** — if the typed code matches an active row, link it to the signup but leave `used_at` NULL. The code stays redeemable until a promotion confirms it.
2. **Promote** — set `used_at` + `used_by_company_id`, flipping the code's status to `used`. After this it can no longer be redeemed.

This lets a signup attempt expire harmlessly without burning the code.

**Files (12):**
- Backend: 2 migrations (`2026_05_05_390001_create_activation_codes_table.php`, `..._390002_add_activation_code_fields_to_tenant_signups.php`), `ActivationCode` model with scopes (`active`/`used`/`revoked`) + `status()` accessor + `generateCode()` helper (XK7-BPQR-style, collision retry, ULID-slice fallback), `ActivationCodeController` (`index` with KPIs + status/q filters, `store`, `show`, `revoke` rejecting non-active with `CODE_NOT_ACTIVE`), `SignupController` accepts optional `activation_code` and never fails signup on invalid/revoked/used, `TenantProvisioningService::promoteSignup` branches on `signup.activation_code_valid` (trial_unlimited + 90-day `period_end` wins over override.plan, marks code consumed, returns `activation_code_used` + notes + code in response), `provision()` accepts `period_end` passthrough to `SubscriptionService::assignPlan`.
- Routes: 4 new under `/platform/activation-codes` gated by `platform.activation_codes.manage`.
- Seeder: `trial_unlimited` plan (all 7 modules, all 7 premium features, 999 limits, monthly cycle, free, sort_order=99) + `public_plan_settings` row pinning `is_public=false` so it stays off the public `/pricing` page; new permission `platform.activation_codes.manage` granted to superadmin only via `$allPerms`.
- `tests/TestCase.php::everyPermission` includes the new permission so test fixtures see it.

**Tests:** **+13** new in `ActivationCodeTest` (CRUD with auto + custom codes, duplicate rejection; revoke active + reject non-active; list filter by status; only superadmin can manage; signup with valid/revoked/unknown/already-used code; promote with valid code grants trial_unlimited regardless of override.plan with 90-day period; promote without code stays on starter regression guard). Full Feature suite: **550 tests / 4,350 assertions**.

**Decisions (locked with user):**
- A. Permission goes to **superadmin only**. No other role.
- B. `trial_unlimited` plan is **not public**. `public_plan_settings.is_public=false`.
- C. **90-day window, no auto-downgrade.** The plan stays `trial_unlimited` after expiry until manually changed; auto-downgrade is a follow-up if it ever bites.

**Debug along the way:** route cache served stale routes during testing → `php artisan route:clear` fixed it. Validation envelope assertion mismatches (`assertJsonValidationErrors` vs custom `error.fields`) corrected to use `error.code = 'VALIDATION_FAILED'` + `error.fields` checks throughout.

---

#### 7. `6a60ed5` — `feat(platform)`: activation codes management page in platform sidebar

**What:** Frontend CRUD against the `c556186` endpoints. Mirrors the Tenants / Staff page pattern: KPI strip (Total / Active / Used / Revoked), status filter pills, search box on code OR notes, table with row → detail drawer, create modal.

**Files (7):**
- `app/src/modules/platform/ActivationCodesPage.jsx` *(new)* — list + filters + create button + detail drawer with revoke action.
- `app/src/modules/platform/_shared/ActivationCodeCreateDialog.jsx` *(new)* — Auto-generate vs Custom code toggle, optional notes textarea, surfaces backend uniqueness validation via ErrorBanner.
- `platform.api.js` — 4 new methods.
- `platform.hooks.js` — 4 new hooks with invalidate-on-success.
- `App.jsx` — lazy-mounts `/app/platform/activation-codes` behind `ProtectedRoute permission='platform.activation_codes.manage'`.
- `nav.config.jsx` — sidebar entry under Platform group.
- `core/auth/permissions.js` — adds the perm to the catalog (no `defer` flag — backend already seeded it).

**Tests:** none (pure frontend; backend covered in commit 6).

`verify:endpoints` 305 → 305 (now resolving 4 new routes); `verify:undefined` clean.

---

#### 8. `e5ba3d8` — `feat(signup)`: activation code on public form + display on platform signups queue

**What:** Public form gets an optional `activation_code` field at the bottom of the compliance section, with helper copy that an invalid code never fails the application. Platform Signups queue surfaces the activation context so the admin sees it BEFORE deciding to promote.

**Files (2):**
- `app/src/modules/public/PublicSignup.jsx` — new optional input under Section 03; submission trims empty values.
- `app/src/modules/platform/SignupsPage.jsx` — per-row gold "ACTIVATION" badge stacked under the status badge for `activation_code_valid=true`; detail drawer adds a gold-bordered card showing the code + a one-line explanation that promotion will assign trial_unlimited (regardless of override.plan) with a 90-day window; **invalid attempts** (`activation_code_attempted` set but `valid=false`) render in a rose-bordered card with the code crossed out so malicious / typo'd attempts are visible without elevating them.

**Tests:** none (no backend change; columns were already serialised by the existing list endpoint via `TenantSignup` fillable). Feature suite still **550 tests / 4,350 assertions**.

**Decisions:** never block signup on an invalid code — the goal is silent funnel completion + audit trail, not friction.

---

#### 9. `3b80480` — `feat(platform)`: system-generated opaque tenant slugs

**What:** Replace the legacy `Str::slug + suffix` scheme with an atomic, opaque slug generator. New format is exactly **6 chars**: `[sequence_number][random_lowercase_letters]`. Letter slots shrink as the digit count grows (1–9 → 5 letters, 10–99 → 4, … 100000+ → no letters).

The sequence is consumed atomically from a single-row `slug_sequence` table inside its own short transaction (`lockForUpdate`), so it doesn't hold a lock across the longer `provision()` transaction. **Sequence gaps on failed provisions are accepted by design** — performance under concurrent promote pressure beats contiguity.

`PlatformController::promoteSignup` and `createTenant` no longer accept a `slug` field; the legacy `SLUG_TAKEN` 422 path is removed (slug collisions are now handled by `SlugGenerator`'s retry loop and its own `SLUG_GENERATION_FAILED` envelope). The `tenant.slug` field is added to `shapeTenant()` so the platform UI can show it to staff after a promote/create — merchants are told their workspace slug out-of-band (until commit 10).

**Files (7):**
- `backend/app/Services/Platform/SlugGenerator.php` *(new)* — atomic counter + retry loop, lives in `App\Services\Platform`.
- `backend/database/migrations/2026_05_05_400001_create_slug_sequence_table.php` *(new)* — single-row `slug_sequence` table seeded with `current_value=0`.
- `TenantProvisioningService.php` — DI'd `SlugGenerator`, replaced `ensureUniqueSlug()` with `slugs->generate()`, dropped `slug` from docblock and from both `promoteSignup` callers.
- `PlatformController.php` — dropped `slug` from `promoteSignup` + `createTenant` validators; added `slug` to `shapeTenant()` response.
- `tests/Feature/SlugGenerationTest.php` *(new)*, `TenantProvisioningTest.php` + `ActivationCodeTest.php` — drop slug overrides, read auto-generated slug from response.

**Tests:** **+5** in `SlugGenerationTest` (sequence increment 1, 2, 3 across promotes; digit-letter ratio at every boundary 9, 10, 99, 100, 999, 1000, 9999, 10000, 99999, 100000; collision retry; ACME-slug preservation; slug field rejected on both `/promote` and `/tenants`). Existing tests modified to drop slug overrides. Feature suite: **555 tests / 4,413 assertions**.

**Decisions (locked with user):**
- **Option 1 — sequence gaps are acceptable.** Performance > contiguity. The slug isn't customer-facing in a way that gap visibility hurts.
- ACME demo + 16 seeded demonstrators **keep their hand-crafted slugs**. The new format only applies to tenants flowing through `TenantProvisioningService::provision()`.
- Platform admin **cannot** override the slug at promote or createTenant time. Slug becomes 100% system-generated.

**Follow-up captured:** the new format is fully opaque to merchants (they have no way to guess `7abcde`), so a merchant-facing `/signup/status` lookup becomes critical. → commit 10.

---

#### 10. `99dd8b5` — `feat(signup)`: public `/signup/status` lookup page (no auth, throttled)

**What:** Adds a merchant-facing reference-number lookup so applicants can discover their application's outcome and — when promoted — the auto-generated workspace slug. Critical because of `3b80480`.

**Backend:**
- `GET /signup/status/{reference}` — anonymous, `throttle:10,1`. Returns one of three shapes by `data.status`:
  - `pending` → bilingual neutral message, **no PII**.
  - `rejected` → bilingual generic + staff-typed reason if set.
  - `active` → `slug`, signup `email`, `login_url`, plan name (en/ar), `activation_code_used` flag.
- 404 envelope is identical for unknown vs malformed references so a guesser can't distinguish "wrong format" from "wrong number".
- New column `tenant_signups.rejection_reason` (255, nullable). `PlatformController::rejectSignup` now persists the field instead of throwing it away — the platform drawer's existing rejection-reason card was effectively dead until this commit. Internal `notes` column is **never** returned by the public endpoint.
- Global exception handler emits a bilingual envelope for 429s (`RATE_LIMITED`), consistent across every throttled route.

**Frontend:**
- `SignupStatusPage.jsx` at `/signup/status` — single input, three result cards (amber pending / rose rejected / green active with slug + Copy + Sign-in deep-link). Bilingual + RTL. Reads `?ref=S-NNNN` to auto-run one lookup, then strips the param so refresh re-prompts (preserves rate-limit budget).
- Discoverable from three places: the signup success panel (primary CTA, ref pre-filled), the public site footer (Product column), the login page (small "Don't have your workspace yet?" link).
- The login workspace form pre-fills `company_slug` from a `?workspace=<slug>` query param, so the active-card "Sign in to your workspace" deep-link works in one click.
- `FinalCtaBanner` hidden on `/signup/status` so a rejected applicant doesn't see "Ready to run every part of your business?" beneath their rejection.

**Files (14):**
- Backend: `Auth/SignupStatusController.php` *(new)*, `bootstrap/app.php` (429 envelope), `routes/api.php`, `Models/TenantSignup.php` (fillable), `Platform/PlatformController.php` (rejectSignup persistence), `database/migrations/2026_05_05_410001_add_rejection_reason_to_tenant_signups.php` *(new)*, `tests/Feature/SignupStatusTest.php` *(new)*.
- Frontend: `public/SignupStatusPage.jsx` *(new)*, `public/public.api.js`, `public/public.hooks.js`, `public/PublicSignup.jsx` (success panel), `public/PublicLayout.jsx` (footer + banner suppression), `auth/LoginPage.jsx` (workspace pre-fill + footer link), `app/App.jsx` (route).

**Tests:** **+10** in `SignupStatusTest` — pending neutral payload (no PII leak); review/verified statuses collapse to pending; rejected returns staff reason when set, null when unset; **end-to-end test**: reject endpoint → public lookup share the same column; active returns slug + email + login_url + plan name (en/ar); active flags `activation_code_used`; unknown reference → neutral 404; malformed reference → same neutral 404; 11th request from same IP within 60s → bilingual 429. Feature suite: **565 tests / 4,481 assertions**.

**Decisions:**
- Same neutral 404 envelope for unknown AND malformed references — no shape-leak. Reference format (`/^S-\d{1,8}$/`) is validated but the failure is silent.
- Persist staff-typed rejection reason in a dedicated `rejection_reason` column rather than mining the audit log or repurposing internal `notes`. The pre-existing platform drawer was already rendering this field — the commit just stops the controller from throwing it away.

**Follow-up captured:** none from this commit. The `/signup/status` page closes the loop opened by commit 9.

### What's parked at the end of session, on the working tree, not committed

- `app/scripts/perm-diff.mjs` *(untracked)* — proposed guard script that diff's the frontend permission catalog against the backend seeded set, including `defer:` flag awareness for catalog entries that are intentionally not seeded yet.
- `app/package.json` *(modified)* — adds a `verify:perms` npm script wiring the script above.
- `app/src/core/auth/permissions.js` *(modified)* — adds `defer: '<reason>'` flags to catalog entries that are intentionally not seeded, and a `reports.view` typo fix.

Held back pending the tenant ↔ platform separation refactor scoping, per locked decision earlier in the session.

### Test trajectory across the session

| Commit | Tests | Assertions | Δ tests |
|---|---:|---:|---:|
| Pre-session baseline | ~493 | — | — |
| `9087cd3` (POS hotfix) | 494 | — | +1 |
| `1a2b72c` (merchant password) | 537 | 4,293 | +43 (4 new + 39 existing reshaped) |
| `c556186` (activation codes BE) | 550 | 4,350 | +13 |
| `3b80480` (opaque slugs) | 555 | 4,413 | +5 |
| `99dd8b5` (signup status) | **565** | **4,481** | +10 |

Frontend has no Vitest setup; coverage stays on `verify:endpoints` (0 missing routes throughout) + `npm run build` sanity passes.

### Permanent follow-ups carried out of this session

1. **Server-side permission middleware on accounting routes** — separate scope. Captured at commit 1.
2. **Tier-1 perm-diff guard script + catalog `defer:` flags** — parked on working tree, not committed. Awaiting tenant ↔ platform refactor scoping.
3. **Activation-code auto-downgrade after the 90-day window** — explicitly out-of-scope per locked decision C. The plan stays `trial_unlimited` until manually changed.
4. **Mock-pinned page wiring (~47 wireable pages, 14 commits, ~3,800 lines)** — separate effort. Plan documented; Batch 1 (Owner) starts after this WORK-LOG entry lands.

---

## Wiring batch 1 — Owner / MSP (5 pages, full mock → live)

First batch of the **mock → real** wiring effort outlined in the prior section. All 5 Owner pages move off mock and onto the real backend; the legacy fixture domain (KYC banking *documents*, bilingual quarterly goals with `unit`, an MSP "plan card") is reshaped against the real domain (bank *accounts* with balances, single-language goals, no MSP plan card — entitlements live in the existing `<EntitlementsCard />`).

### Mid-batch scoping check (new rule)

**Rule established with this batch:** before writing code, every batch does a quick scoping check. If the missing backend work is small (≤150 lines) and clearly scoped → bundle silently. If larger or introduces architectural decisions → STOP, present three options (bundle / defer / skip-as-roadmap with DemoBanner). The reconciler caught the Owner discrepancy: `MerchantsPage` couldn't render against the existing `OwnerController::merchantsIndex` response (lean pivot rows only, no name/plan/status/etc.), and there were no `pauseMerchant` / `resumeMerchant` routes at all. **Locked decision: ship Option C — full wiring including pause/resume + enriched merchants payload.**

### Backend

* **`app/Http/Controllers/Owner/OwnerController.php`** — major rewrite of `merchantsIndex` + new `pauseMerchant` / `resumeMerchant`; `bankingIndex`, `goalsIndex`, `legalIndex` upgraded to the canonical `{ items, kpis }` envelope so the frontend renders KPI strips without a second round-trip.
  - **Banking** rows expose `bank_name`, `iban`, `balance`, `currency`. KPIs roll up `total` + `total_balance` + dominant `currency`.
  - **Goals** preserve the model's single-language `title`, expose `progress` derived from `current/target * 100`, and split KPIs by status (`on_track / at_risk / behind / done`).
  - **Legal** derive their effective status from `expires_on` server-side: missing → `valid`; past → `expired`; within 30 days → `expiring`. The persisted column is reset accordingly on read so the merchant doesn't have to flip it manually.
  - **Merchants** roll up `name_en/_ar`, `plan`, `status`, `mrr`, `branches_count`, `users_count`, **30-day GMV** (single `SUM…GROUP BY` against `pos_sales` with `withoutGlobalScopes()` because the `BelongsToTenant` scope would otherwise constrain to the caller's own `company_id`), and `joined` date. Optional `?status=active|trial|paused` and `?q=...` filters. KPIs total / active / trial / paused / monthly_gmv across the filtered set.
  - **`pauseMerchant` / `resumeMerchant`** flip `companies.status` between `active` and `paused` on the linked merchant. The 6-char status free-string column already accepts `paused` (no migration). Cross-tenant guard via the OwnerMerchant pivot's `owner_company_id`. **404 on cross-tenant or unknown pivot** (not 403 — don't leak existence). Idempotent: re-pausing an already-paused merchant returns 200 without writing a second audit row. Both actions audited at `severity=medium` with `scope=tenant`.

* **`backend/routes/api.php`** — two new POST routes under the existing tenant group: `/owner/merchants/{id}/pause`, `/owner/merchants/{id}/resume`. No new permission key — `business-owner` role already carries the `owner` umbrella permission.

* **No migration.** `companies.status` is a free string column; `paused` slots in alongside the existing `active / trial / overdue / suspended`.

### Frontend

* **`owner/owner.api.js`** — switched all 7 endpoints from `mockOnlyRequest` to `realRequest`. Renamed `plan: () => /owner/plan` → `home: () => /owner/home`.
* **`owner/owner.hooks.js`** — `useOwnerPlan` → `useOwnerHome`.
* **`owner/OwnerHomePage.jsx`** — dropped the legacy gold "Plan" card (the entitlements / seats / branches / next-invoice data has no real backend; `<EntitlementsCard />` already covers it via `/me/entitlements`). New top-level KPI grid uses real `/owner/home`: sales 30d / customers / products / goals done. Second strip: open AR / open AP. Group/MSP roll-up only renders when `merchants.kpis.total > 0` (single-tenant owners get a clean dashboard). Top goals card now uses single-language `title` and renders the empty-state message when there are none.
* **`owner/BankingPage.jsx`** — repurposed from "KYC documents" to "bank accounts": each row shows bank name + IBAN + balance + currency. KPI strip is `Accounts` + `Total balance`. The legacy doc-status badge column (`valid / expiring / expired`) is removed because it doesn't apply to accounts.
* **`owner/LegalPage.jsx`** — fields renamed to match the BE (`label`, `expires_on`). Status badge tone preserved; status comes from the BE's `expires_on`-derived value, not the persisted column.
* **`owner/GoalsPage.jsx`** — single-language `title`, `due_on` rendered as `due`, drop the `unit`-conditional formatting. KPI strip widened to 4 (Total / On track / At risk / Done) so `done` is visible.
* **`owner/MerchantsPage.jsx`** — only metadata change: extended `PLAN_TONE` to map `growth → info`, `trial_unlimited → gold` (the original constant referenced an obsolete `pro` tier that doesn't exist in the live `subscription_plans` enum). The page already renders the right shape now that the backend returns enriched rows.

### Tests

`backend/tests/Feature/OwnerWiringTest.php` *(new — 12 tests, 70 assertions)*:

- `home_returns_kpi_envelope` — shape contract.
- `banking_returns_items_and_kpis` — sums + alphabetical sort.
- `goals_envelope_derives_kpis_per_status` — progress math + status splits.
- `legal_status_derives_from_expires_on` — 4 expiration scenarios (no expiry / future / 20-days-out / past) all rendered correctly even when the persisted column says `valid`.
- `merchants_returns_enriched_rows_with_company_fields_and_gmv` — end-to-end joins + 30-day GMV from real `PosSale` rows.
- `merchants_status_filter` — `?status=active`, `?status=paused` correctly narrow the result.
- `pause_merchant_flips_status_and_audits` — column flip + AuditLog row.
- `resume_merchant_restores_to_active` — symmetric.
- `pause_is_idempotent_when_already_paused` — no audit row on no-op.
- `pause_rejects_cross_tenant_pivot_with_404` — tenant A trying to pause tenant B's merchant gets 404 + no DB change. Belt-and-braces: target merchant's status verified unchanged.
- `pause_rejects_unknown_pivot_with_404`.
- `banking_and_goals_only_return_caller_company_rows` — explicit cross-tenant isolation regression guard.

`backend/tests/Feature/OwnerEcomZfTest.php` — pre-existing `test_owner_goals_progress_computed` updated for the new `{ items, kpis }` envelope; +2 assertions on the kpis block.

**Backend suite: 565 → 577 tests / 4,481 → 4,553 assertions, all green.**

### Endpoint diff

`verify:endpoints --strict`:

```
unique_live_endpoints       306  →  313
unique_mock_only_endpoints  146  →  139
backend_routes              473  →  475
matched                     306  →  313
missing                       0  →    0
```

### What stayed demo

Nothing. All 5 Owner pages (`OwnerHomePage`, `BankingPage`, `LegalPage`, `GoalsPage`, `MerchantsPage`) are now fully wired to real backend endpoints, including pause/resume actions. No DemoBanners on the Owner sidebar group.

### Files touched

```
backend/app/Http/Controllers/Owner/OwnerController.php  (major rewrite)
backend/routes/api.php                                  (+2 routes)
backend/tests/Feature/OwnerWiringTest.php               (new, 12 tests)
backend/tests/Feature/OwnerEcomZfTest.php               (1 assertion shape upgrade)
app/src/modules/owner/owner.api.js                      (mock → real, rename plan→home)
app/src/modules/owner/owner.hooks.js                    (useOwnerPlan → useOwnerHome)
app/src/modules/owner/OwnerHomePage.jsx                 (rebuilt; gold plan card dropped)
app/src/modules/owner/BankingPage.jsx                   (repurposed: docs → accounts)
app/src/modules/owner/LegalPage.jsx                     (field-key adapter)
app/src/modules/owner/GoalsPage.jsx                     (single-language; +done KPI)
app/src/modules/owner/MerchantsPage.jsx                 (PLAN_TONE constants)
```

---

## Wiring batch 2 — E-com Orders (1 page, full mock → live)

Second batch of the wiring effort. Only the **Orders** tab in the E-com module wires live (Catalog / Fulfillment / Returns / Customers stay on mock — no backend surface for them yet, the EcomPage shell continues to render `<DemoBanner />`).

### Mid-batch scoping check (per the new rule)

Initial estimate was a pure envelope adapter (~120 lines). On opening the controller the gap was bigger than that:

| FE expected | Real BE | Resolution |
|---|---|---|
| `?status` + `?channel` filter params | `EcomOrder` has 5 statuses; no channel concept | Drop FE channel selector + 5 demo channel values; keep status filter, BE adds `?status=...` validation |
| 9-status lifecycle (placed → confirmed → preparing → fulfilled → shipped → delivered → completed → cancelled / returned) | 5 statuses (placed / paid / fulfilled / cancelled / returned) | FE collapsed to the 5 real statuses |
| Generic `advance` and `cancel` endpoints | `pay`, `fulfill`, `return` discrete actions; no `advance`, no `cancel` | FE rebuilt with explicit per-status buttons; BE gains a new `cancel` route |
| `customer_name`, compact `address`, `tracking`, `courier`, `channel` per row | `customer_id`, `shipping_address` JSON, fulfillments in a separate table | BE eager-loads `Customer:id,name`, derives `address_line` from `street + city`; tracking/courier dropped from the list (would require composite call) |
| `kpis` on the response | `{data, meta}` paginated array | BE adds `kpis` block computed across the FULL tenant set so the strip is stable while filtering/paging |

Total bundled BE work: **~85 lines** (status filter + KPI block + customer-name eager-load + the new `cancel` endpoint with `INVALID_TRANSITION` envelope). **Under the 150-line threshold → bundled silently per the new rule.**

### Backend

* **`EcomController::ordersIndex`** rewritten:
  - Validates optional `?status=all|placed|paid|fulfilled|cancelled|returned` and pagination params.
  - Eager-loads `customer:id,name` so the list shape carries `customer_name` without a second round-trip.
  - Builds an `address_line` string from `shipping_address.street + city` (the JSON column itself is still in the response).
  - Adds an `items_count` (counted server-side from `lines`) so the FE doesn't have to materialise the lines on every render.
  - Computes a `kpis` block across the FULL tenant set: `total / placed / paid / fulfilled / returned / cancelled / revenue_today` (the last summed from today's `paid` + `fulfilled` orders only — placed-but-unpaid and refunds are excluded). KPIs deliberately span the unfiltered set so the strip stays stable while filtering or paging.
* **`EcomController::ordersCancel`** (new) — `POST /ecom/orders/{order}/cancel`. Only valid from `placed` or `paid`; otherwise returns `422 INVALID_TRANSITION`. Cross-tenant guard via the existing `guardSameTenant`.
* **`EcomOrder` model** — added the missing `customer()` relation (`belongsTo(Customer::class)`).
* **`backend/routes/api.php`** — one new route (`/ecom/orders/{order}/cancel`) inside the existing `module:ecom` group.

### Frontend

* **`ecom/ecom.api.js`** — split into a "live" block (orders + pay / fulfill / return / cancel via `realRequest`) and a "still demo" block (catalog / fulfillments / returns / customers via `mockOnlyRequest`). Removed the obsolete `advanceOrder` (no backend surface) and the `approveReturn` / `rejectReturn` (no list endpoint). Header comment updated to match the new live/mock split.
* **`ecom/ecom.hooks.js`** — replaced `useAdvanceOrder` with explicit `usePayOrder`, `useFulfillOrder`, `useReturnOrder`. `useCancelOrder` kept but now points at the real cancel route.
* **`OrdersPage.jsx`** — substantially reshaped:
  - 5 status options instead of 9, no channel selector.
  - Per-row action button is now explicit:
    - `placed` → **Mark paid** (POST `/pay` with `amount = total`, `method = 'mada'`).
    - `paid` → **Mark fulfilled** (POST `/fulfill` with `carrier = 'In-house'`).
    - `fulfilled` → **Process return** (POST `/return` with `refund_amount = total`).
    - `placed` or `paid` → **Cancel** (POST `/cancel`).
  - Customer name comes from the eager-loaded BE response; falls back to "Walk-in customer" when null. `address_line` rendered as a single line under the customer name.
  - KPI strip widened to use the new BE `kpis` block (Orders / Placed / Fulfilled / Revenue today).
  - Tracking / courier line removed — that data lives in the separate `ecom_fulfillments` table and would require a composite call. To revisit if Fulfillment tab gets its own backend.

### Tests

`backend/tests/Feature/EcomWiringTest.php` *(new — 6 tests, 53 assertions)*:

- `orders_index_returns_items_kpis_envelope_with_customer_name` — full structure + customer name + `address_line` + `items_count`.
- `orders_index_status_filter` — `?status=placed` and `?status=paid` correctly narrow the page; KPIs still span the full tenant set.
- `orders_cancel_only_from_placed_or_paid` — happy path + 422 `INVALID_TRANSITION` on already-cancelled.
- `orders_cancel_rejects_fulfilled_with_invalid_transition` — guard against fulfilled → cancel.
- `orders_index_does_not_leak_other_tenant_orders` — explicit cross-tenant isolation regression.
- `revenue_today_excludes_unpaid_and_caps_at_today` — placed-but-unpaid (999) excluded; `paid + fulfilled` (115 + 230 = 345) included.

The pre-existing `OwnerEcomZfTest::test_ecom_order_lifecycle` (create → pay → fulfill) still passes against the upgraded shape.

**Backend suite: 577 → 583 tests / 4,553 → 4,613 assertions, all green.**

### Endpoint diff

`verify:endpoints --strict`:

```
unique_live_endpoints       313  →  318
unique_mock_only_endpoints  139  →  136
backend_routes              475  →  476
matched                     313  →  318
missing                       0  →    0
```

### What stayed demo

- **`CatalogPage`** — no `/ecom/catalog` backend surface.
- **`FulfillmentPage`** — fulfillments only exist as an internal write; no list endpoint.
- **`ReturnsPage`** (returns list) — only `POST /ecom/orders/{id}/return` exists; no list / approve / reject endpoints.
- **`CustomersPage`** (E-com customers) — no `/ecom/customers` surface (the tenant-side `/customers` is the real customer roster, but it's not e-com-segmented).

The `EcomPage` shell continues to render `<DemoBanner />` because of these four tabs. After Orders ships live, the banner copy could be tightened to call out that only Orders is live, but that's UI polish for a follow-up.

### Files touched

```
backend/app/Http/Controllers/Ecom/EcomController.php   (+ status filter, kpi block, customer join, cancel route)
backend/app/Models/EcomOrder.php                        (+ customer relation)
backend/routes/api.php                                  (+1 route)
backend/tests/Feature/EcomWiringTest.php                (new, 6 tests)
app/src/modules/ecom/ecom.api.js                        (split live / mock; -advance, +cancel/pay/fulfill/return)
app/src/modules/ecom/ecom.hooks.js                      (-useAdvanceOrder, +usePay/Fulfill/Return)
app/src/modules/ecom/OrdersPage.jsx                     (reshape: 5 statuses, explicit per-status buttons, drop channel/tracking)
```

---

## Wiring batch 3 — Retail Customers list + 360 drawer (mock → live)

Third batch. The shared **Customers list** + **Customer 360 drawer** wire live against `/customers`. POS checkout's customer search now reads from real customers too. The 5 CRM sub-tabs (Segments, Communications, Tasks, Birthdays, Opportunities) stay mock for Batch 4.

### Mid-batch scoping check

The drift was meaningful — the FE was modelled around mock fixture columns the backend doesn't carry:

| FE field | Real source | Resolution |
|---|---|---|
| `lifetime_value` | `customers.total_spend` | shape() exposes both — `lifetime_value` as alias |
| `loyalty_points` | derived | shape() returns `floor(total_spend / 10)` (10 SAR = 1 point heuristic) |
| `last_purchase` | composite from `pos_sales` | single `MAX(created_at) GROUP BY customer_id` query, joined per-page |
| `city` | `customers.meta.city` (JSON column) | shape() reads from meta |
| `tier` (proper-case) | `customers.tier` (lowercase enum) | shape() returns BOTH `tier` (canonical) AND `tier_label` (proper-case for direct render); filter accepts proper-case + the legacy `'—'` for walk-in |
| KPIs (`total / active_30d / gold_plus / avg_ltv`) | not on response | computed across the FULL tenant set in `index()` |

Total bundled BE work: **~80 lines**. Under the 150-line threshold → bundled silently per the rule.

### Backend

* **`CustomerController::index`** rewritten:
  - Returns `{ items, kpis, meta }` instead of `{ data, meta }`. The `kpis` block (`total / active_30d / gold_plus / avg_ltv`) spans the full unfiltered tenant set so the strip stays stable while the operator filters/pages.
  - `?tier=Platinum|Gold|Silver|—|all` (case-insensitive; `—` and `Walk-in` both map to the lowercase `walkin` enum).
  - `last_purchase` is derived in a single `MAX(created_at) GROUP BY customer_id` query against `pos_sales` for the customers on the current page, then merged into the shape.
  - `active_30d` KPI counts `DISTINCT customer_id` from `pos_sales` in the last 30 days (the only tenant-scoped table that records customer activity at the moment).
* **`CustomerController::shape`** now exposes:
  - `lifetime_value` (alias for `total_spend`)
  - `loyalty_points` (derived heuristic)
  - `last_purchase` (composite from above)
  - `city` (from `meta.city`)
  - `tier_label` (Platinum / Gold / Silver / Walk-in, proper-cased)
  - All previous fields still present — back-compat with `CustomersGrowthTest::test_customer_crud` only required updating one assertion path.
* No new permissions, routes, or migrations.

### Frontend

* **`retail/retail.api.js`** — `customers` switched from `mockOnlyRequest` to `realRequest.get('/customers', params)`. Header comment updated to document the live shape (envelope, derived fields, filters).
* **`retail/customers/ListPage.jsx`** — minimal reshape:
  - `TIER_TONE` keyed by `tier_label` (proper-case) instead of the old `'—'` for walk-in.
  - Filter `Select` value for walk-in changed from `'—'` to `'Walk-in'` so it matches the BE response.
  - Client-side filter and badge rendering both read `r.tier_label`.
  - Search still includes city — works because BE now returns `city` via `meta.city`.
* **`retail/_shared/Customer360Drawer.jsx`** — 4 small changes:
  - Header tier badge reads `customer.tier_label`.
  - Loyalty fixture join switched to `customer.tier_label` (loyalty fixture keys are proper-case).
  - Overview "Tier" stat reads `tier_label`.
  - Loyalty tab title reads `tier_label`.
  - All other fields (`lifetime_value`, `loyalty_points`, `last_purchase`, `city`) work as-is now that the BE shape exposes them.

### Tests

`backend/tests/Feature/CustomersListWiringTest.php` *(new — 7 tests, 48 assertions)*:

- `envelope_shape_carries_kpis_and_meta` — full structure contract.
- `shape_exposes_lifetime_value_loyalty_points_and_city` — derived field math + meta.city read.
- `last_purchase_comes_from_max_pos_sale_created_at` — composite query against pos_sales (uses `forceFill + saveQuietly` to land historic timestamps).
- `tier_filter_accepts_proper_case_and_em_dash` — Platinum / silver / `—` / 'all' / unknown all behave correctly.
- `kpis_are_derived_correctly` — total / gold_plus / avg_ltv math.
- `kpis_span_full_tenant_set_not_the_filtered_page` — KPIs stay stable across filter changes.
- `does_not_leak_other_tenant_customers` — explicit cross-tenant isolation regression.

`backend/tests/Feature/CustomersGrowthTest.php` — pre-existing `test_customer_crud` updated for the `data.meta.total` envelope path (was `meta.total`).

**Backend suite: 583 → 590 tests / 4,613 → 4,661 assertions, all green.**

### Endpoint diff

`verify:endpoints --strict`:

```
unique_live_endpoints       318  →  319
unique_mock_only_endpoints  136  →  135
backend_routes              476  →  476
matched                     318  →  319
missing                       0  →    0
```

### What stayed demo / partial

- **Retail · CRM tabs (Segments / Communications / Tasks / Birthdays / Opportunities)** — these continue to call `/retail/crm/*` (mock). They have a real backend at `/crm/*` (Customers controller) but the path adapter is Batch 4 of the wiring plan.
- **PosPage / TasksPage / CommunicationsPage** — still consume `useCustomers()` for "pick a customer" search; this batch makes that search pull from the real customer roster automatically (no per-page change required because the hook signature is unchanged and the shape's `name`, `phone`, `email`, `tier_label` all match what those pages render).

### Files touched

```
backend/app/Http/Controllers/Customers/CustomerController.php  (+ envelope, kpis, last_purchase composite, tier normalisation, shape upgrade)
backend/tests/Feature/CustomersListWiringTest.php              (new, 7 tests)
backend/tests/Feature/CustomersGrowthTest.php                  (1 assertion path upgrade)
app/src/modules/retail/retail.api.js                            (mock → real /customers)
app/src/modules/retail/customers/ListPage.jsx                   (tier_label keying, '—' → 'Walk-in')
app/src/modules/retail/_shared/Customer360Drawer.jsx            (4 tier_label reads)
```

---

## Wiring batch 4 — Retail CRM (FULL — 5 pages, 4 schema migrations)

Fourth batch. The 5 retail CRM sub-tabs (Segments, Communications, Tasks, Birthdays, Opportunities) wire live with the FULL feature set the FE pages were originally sketched for. The plan called for ~250 lines path adapter; the actual delta is **4 schema migrations + ~750 lines** because the FE was modelled against a richer mock fixture domain than the BE shipped.

### Mid-batch scoping check

User chose **Option C (FULL)** explicitly: schema migrations to add the missing columns rather than dropping FE features. Locked decisions:
- Bilingual fields added to `customer_tasks`, `opportunities`, `customer_communications`.
- `opportunities.probability` added.
- `customer_segments.kind` discriminator added (FE keys segment tone off `kind`, not the ULID `id`).
- `customer_communications.status` already supported `delivered/read/failed` — no new events table needed; FE-friendly aliases (`opened/clicked/bounced`) map at the controller layer.
- `customer_tasks.assignee_id` already existed — only needed bilingual + status enum expansion.

### Schema migrations (4)

1. **`2026_05_05_420001_add_bilingual_fields_to_customer_tasks.php`** — adds `title_en`, `title_ar` to `customer_tasks`. Backfills `title_en` from the legacy `title` column. Status enum stays unconstrained at the DB level so adding `in_progress` is purely a controller-validation change.
2. **`2026_05_05_420002_add_bilingual_and_probability_to_opportunities.php`** — adds `title_en`, `title_ar`, `probability` (smallInt 0-100, default 0) to `opportunities`. Backfills `title_en` from `title`.
3. **`2026_05_05_420003_add_bilingual_fields_to_customer_communications.php`** — adds `subject_en`, `subject_ar`, `preview_en`, `preview_ar`. Backfills `subject_en` from `subject`.
4. **`2026_05_05_420004_add_kind_to_customer_segments.php`** — adds `kind` (string 16, default `custom`). Well-known values: `vip / frequent / lapsed / new / at_risk / custom`.

Models updated with new fillable fields + relations (`CustomerTask::customer/assignee`, `Opportunity::customer/owner`, `CustomerCommunication::customer`).

### Backend — `CustomerController` upgrades

- **`segmentsIndex`** — `{ items, kpis }` envelope. Each segment row carries `kind`, bilingual `en/ar` aliases, `count`, `ltv` (sum of member `total_spend`), `sample` (top 3 members by LTV), and per-`kind` default action labels (e.g. `vip` → "Send VIP offer"). KPIs span all segments: `total_segments`, `total_customers_in_segments` (sum of `member_count`), `ltv_total` (sum of LTVs), `next_action_count` (segments with non-empty action — i.e. all of them).
- **`segmentsStore`** — accepts `kind` (validated against `CustomerSegment::KINDS`).
- **`tasksIndex`** — `{ items, kpis }`. Items eager-load `customer:id,name` + `assignee:id,name`; expose bilingual title + `due` alias for `due_date`. KPIs: `total / open / in_progress / done / overdue / due_this_week`. Overdue = `due < today AND status NOT IN (done, cancelled)`. Due-this-week = `today ≤ due ≤ end_of_week AND status NOT IN (done, cancelled)`.
- **`tasksStore` / `tasksPatch`** — accept `title_en`, `title_ar`, `assignee_id`, and `in_progress` status. `'med'` priority normalised to `'medium'` server-side (FE legacy alias). At least one of `title / title_en / title_ar` required.
- **`commsIndex`** — `{ items, kpis }`. KPIs: `sent_30d`, `delivered`, `opened` (mapped from persisted `read`), `clicked` (also mapped from `read`; the schema doesn't separate click events from reads — when a separate click-tracking story lands it'll overwrite this branch), `failed`. Status filter accepts FE-friendly aliases (`opened` → persisted `read`, `bounced` → `failed`).
- **`commsSend`** — accepts bilingual subject + preview. Backfills legacy `subject` from `subject_en`.
- **`opportunitiesIndex`** — `{ items, kpis, stages }`. The `stages` array drives the kanban: `[{id, en, ar, tone}]` for `lead → qualified → proposed → won → lost`. KPIs: `pipeline` count + value (excluding won/lost), `won_value`, `lost_value`. Items expose bilingual title, `probability`, and `close_date` alias for `expected_close`.
- **`opportunitiesStore` / `opportunitiesPatch`** — accept `title_en`, `title_ar`, `probability`. **Stage transitions auto-bump probability**: when an opportunity advances stage AND the caller didn't supply an explicit probability, the new stage's default is applied (`lead=10, qualified=30, proposed=60, won=100, lost=0`).
- **`birthdays`** — composite shape with the same enrichment as the Customers list: `last_purchase` (composite from `pos_sales.MAX(created_at) GROUP BY customer_id`), `loyalty_points` (heuristic), `tier_label`, `days_away` (today=0). KPIs: `today / this_week / this_month` + a `count` legacy alias.

### Frontend

- **`retail/retail.api.js`** — switched all CRM endpoints from `mockOnlyRequest` to `realRequest`. Birthdays goes to `/customers/birthdays` (it's served by `CustomerController`). Path comment updated.
- **`SegmentsPage.jsx`** — segment tone keyed by `s.kind` instead of `s.id` (the BE now returns ULIDs for IDs, not the legacy fixture strings). `TONE` map gains a `custom` neutral fallback. Segment cards + member modal both render `tier_label` for proper-cased badge labels and ULID-truncated IDs.
- **`CommunicationsPage.jsx`** — `STATUS_TONE` extended so the BE's persisted `read/failed/sent` enum values render with the same tones as the FE-friendly `opened/bounced/queued`.
- **`TasksPage.jsx`** — `PRIO_TONE` extended to map `medium` (canonical BE) plus the legacy `med` alias. `STATUS_TONE` gains a `cancelled` neutral key.
- **`BirthdaysPage.jsx`** — tone badge keyed by `tier_label` to match the lowercase enum the BE returns.
- **`OpportunitiesPage.jsx`** — no changes needed; the page already binds against `data.items / data.stages / data.kpis / it.title_en / it.probability / it.close_date` which are now all in the BE response.

### Tests

`backend/tests/Feature/RetailCrmWiringTest.php` *(new — 11 tests, 168 assertions)*:

- Segments: envelope contract + KPI math + sample customers + default action labels per kind.
- Tasks: envelope + KPI math + assignee join + bilingual title + `med→medium` normalisation + `in_progress` status acceptance.
- Comms: envelope + KPI mapping (delivered/read/failed) + bilingual subject persistence + status-filter alias (`opened`/`bounced`).
- Opportunities: envelope contract + stage array order + probability defaults per stage + auto-bump on stage transition.
- Birthdays: composite shape + `days_away` math + KPI strip + cross-tenant isolation.

`CustomersGrowthTest::test_campaign_run_tags_recipients` keeps passing — segment store still returns `data.id` at the root.

**Backend suite: 590 → 601 tests / 4,661 → 4,807 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       319  →  329
unique_mock_only_endpoints  135  →  125
backend_routes              476  →  476
matched                     319  →  329
missing                       0  →    0
```

### What stayed demo

Nothing in the CRM tab. All 5 sub-tabs (Segments, Communications, Tasks, Birthdays, Opportunities) are now fully wired live. Only the Customers tab's last remaining mock — **CampaignsPage** — stays demo (no `/campaigns` backend surface for the marketing automation flow yet); that's a separate sub-tab.

### Files touched

```
backend/database/migrations/
  2026_05_05_420001_add_bilingual_fields_to_customer_tasks.php          (new)
  2026_05_05_420002_add_bilingual_and_probability_to_opportunities.php  (new)
  2026_05_05_420003_add_bilingual_fields_to_customer_communications.php (new)
  2026_05_05_420004_add_kind_to_customer_segments.php                   (new)
backend/app/Models/CustomerTask.php             (+ title_en/_ar fillable, customer/assignee relations)
backend/app/Models/Opportunity.php              (+ title_en/_ar/probability fillable, customer/owner relations)
backend/app/Models/CustomerCommunication.php    (+ subject/preview en/ar fillable, customer relation)
backend/app/Models/CustomerSegment.php          (+ kind fillable, KINDS const)
backend/app/Http/Controllers/Customers/CustomerController.php   (5 endpoint groups rewritten;
                                                                  shapeTask/shapeComm/shapeOpportunity helpers)
backend/tests/Feature/RetailCrmWiringTest.php   (new, 11 tests)
app/src/modules/retail/retail.api.js                             (CRM endpoints mock → real)
app/src/modules/retail/customers/SegmentsPage.jsx                (kind-keyed tone, tier_label labels)
app/src/modules/retail/customers/CommunicationsPage.jsx          (STATUS_TONE extended)
app/src/modules/retail/customers/TasksPage.jsx                   (PRIO_TONE extended)
app/src/modules/retail/customers/BirthdaysPage.jsx               (tier_label keying)
```

---

## Wiring batch 5 — POS sales + returns (FULL — schema migration + PSP stub seam)

Fifth batch. The cashier path — by far the highest-impact demo flow — wires live: Sell → Checkout → Receipt persists a real `pos_sales` row with `pos_sale_lines` + `pos_sale_payments`, processed through the bound `PaymentProvider`. Returns wizard posts to `/pos/sales/{id}/return` against the real backend; the new `/pos/returns` endpoint feeds the Returns history page.

### New rule established this batch — third-party stubs

User decision recorded mid-batch: **anything that requires a real third-party integration (PSP, email/SMS, ZATCA crypto, bank APIs, OAuth, …) gets a working STUB with a clean swap point**. The stub must look real to the rest of the system (correct response shapes, latency, error cases), use the real vocabulary/schema (mada, visa, stcpay — not generic `card`), and have a documented seam so the real provider plugs in by config flip, not refactor. **Goal: when the real integration lands, it's a one-place swap.**

Retroactive review of earlier batches:
- Batch 1 (Owner pause/resume) — no third-party seam touched.
- Batch 2 (E-com Orders) — actions are persistence-only, no PSP / carrier integration.
- Batch 3 (Customers list) — pure data joins.
- Batch 4 (CRM communications) — `commsSend` is *effectively* a stub already (writes a row, doesn't actually send), but the seam isn't named. **Logged as a small follow-up: formalise as `NotificationProvider` + `StubNotificationProvider`** in a separate commit.

### Mid-batch scoping check

User chose **Option C (FULL)** explicitly: schema migration to add the Saudi-specific tender_brand + provider_ref columns rather than dropping mada/visa/stcpay branding from the FE.

The FE was sketched against rich mock fixtures — the cashier UI distinguishes mada from visa, charges through a (fake) processor, and renders branded receipts. The BE shipped a leaner canonical tender enum (`cash | card | wallet | gift_card | store_credit`) that loses the brand. Bridging required:

| FE expects | Real BE | Resolution |
|---|---|---|
| `useSales()` → `{items, kpis}` with today_count / today_total / avg_basket / refund_rate | `{data, meta}` paginated array, no KPI block | rich envelope rebuild |
| Sale rows with flat strings: `branch` (label), `cashier` (name), `items` (count), `payment` (`mada`) | FK ids + nested `lines[]` + `payments[]` arrays | composite eager-load + aggregation |
| `useReturns()` list | `POST /pos/sales/{id}/return` action only — no list endpoint | **new `GET /pos/returns` endpoint** |
| Tender vocab: `mada / visa / cash / stcpay / giftcard` | `cash / card / wallet / gift_card / store_credit` | **schema migration: `tender_brand` column** |
| `useCreateSale` body: `{branch, cashier, lines [FE cart shape], payment, tenders}` | `{shift_id, customer_id, channel, lines:[{product_id, qty, unit_price}], payments:[{tender, amount, ref}]}` | CheckoutModal rebuild + active-shift lookup |

Total bundled BE work: **~470 lines** (way over the 150-line threshold — that's why option C with explicit user approval).

### Schema migration

**`2026_05_05_430001_add_tender_brand_and_provider_ref_to_pos_sale_payments.php`** — extends `pos_sale_payments` with three columns:

- `tender_brand` (string 32, indexed) — the consumer-facing brand label: `mada / visa / mastercard / amex / stcpay / apple_pay / google_pay / urpay`. The canonical `tender` enum stays `cash / card / wallet / gift_card / store_credit` (used for accounting).
- `provider_ref` (string 64) — the PSP's transaction id. `STUB-`-prefixed today; real PSP ids land here when the integration swaps.
- `provider_response` (json) — raw provider response body for audit + dispute resolution.

`pos_returns.refund_payments` is already a JSON column, so brand info embeds directly without a migration.

### PSP stub seam (the new architectural primitive)

**`backend/app/Services/Pay/PaymentProvider.php`** *(rewritten)* — unified interface that subsumes both the existing platform Pay product (charges/refunds/webhooks) and the new POS sale path. Methods:

- `charge(array $request) → {status, provider_ref, provider_response, fee, message}` — request carries `tender`, optional `tender_brand`, `amount`, `currency`, `ref`, `metadata`.
- `refund(string $providerRef, float $amount, array $metadata = [])` — symmetric to charge.
- `verifyWebhook(string $rawBody, string $signature) → bool`.
- `name() → string`.

**`backend/app/Services/Pay/StubPaymentProvider.php`** *(new)* — implements all 4 methods. Approves card / wallet with `STUB-<BRAND>-<RANDOM>` provider_refs and a 1.8% fee model; `cash / gift_card / store_credit` skip the network and return `provider_ref=null, fee=0`. Visible markers everywhere (`provider_response.provider='stub'`, every ref starts with `STUB-`) so this can never be mistaken for a real provider.

**`backend/config/payments.php`** *(new)* — `provider` key (defaults to `'stub'`, env-overridable as `PAYMENTS_PROVIDER`) + a whitelist `brands` array the controller validates against. **Adding a new brand is a one-line config change, no migration.**

**`backend/app/Providers/AppServiceProvider.php`** — binds `PaymentProvider::class` based on `config('payments.provider')`. The `match` is the **swap point**: when a real provider lands, add a new class implementing `PaymentProvider`, add a `case 'mada_net'` arm, flip the env var, and every consumer gets the new behaviour with zero code changes.

**`backend/app/Services/Pay/StubProvider.php`** *(deleted)* — the old legacy stub with the narrower `charge(float, string, array)` signature is removed; the unified interface now covers its surface. `PayController` updated to use the new shape (mapping the legacy `result.fee/raw` keys onto the new `result.fee/provider_response`).

### Backend — `PosController` upgrades

- **`createSale`** validation accepts optional `payments[].tender_brand`. The validation goes through `config('payments.brands')` (`mada / visa / mastercard / amex / stcpay / apple_pay / google_pay / urpay / none`); unknown brands are rejected with `TENDER_BRAND_INVALID`.
- **`PosService::createSale`** now calls `app(PaymentProvider::class)->charge(...)` per payment row before persisting. Card / wallet payments stamp the returned `provider_ref` + `provider_response` on the row; cash and gift_card skip the network. Decline → `PAYMENT_DECLINED` 422 envelope (the entire sale rolls back since the payment loop runs inside the parent transaction).
- **`salesIndex`** — rebuilt envelope: `{items, kpis, meta}`. Items eager-load `lines:id,sale_id,sku,name,qty,unit_price`, `payments:id,sale_id,tender,tender_brand,amount,ref,provider_ref`, `branch:id,code,name_en,name_ar`, `cashier:id,name`. Filter params: `?status=`, `?payment=` (matches either canonical `tender` or consumer-facing `tender_brand`), `?shift_id=`, `?branch_id=`. Items count derived from the eager-loaded `lines`. Primary tender brand surfaced as a flat `payment` field for the FE table. KPIs (`today_count / today_total / avg_basket / refund_rate`) span the FULL tenant set (not the filtered page) so the strip stays stable.
- **`returnsIndex`** *(new)* — `GET /pos/returns`, paginated `{items, kpis, meta}`. Items eager-load `sale:id,ref_no`, `branch`, `cashier`, `lines` (counted). Status is hard-coded to `'refunded'` because the schema doesn't model a pending state — the entire return is committed at write-time. KPIs: `total_30d / refunded / pending / value_30d` (pending=0).
- **`shapeSale`** — payments now include `tender_brand` + `provider_ref` in addition to `tender / amount / ref`.
- New routes:
  - `GET /pos/returns` (added to the `/pos/*` group).

### Frontend

- **`retail/retail.api.js`** — `sales`, `createSale`, `returns`, `createReturn`, `activeShift` all switched to `realRequest` against the canonical `/pos/*` endpoints. `createReturn` now takes `(saleId, body)` instead of a flat body since the BE keys returns by their parent sale.
- **`retail/retail.hooks.js`** — `useActiveShift` query added (`/pos/shifts/active`); `useCreateSale` invalidates the active-shift cache so a sale that touches `expected_cash` re-renders the dialog. `useCreateReturn` signature shifted to `({saleId, body})`.
- **`retail/pos/CheckoutModal.jsx`** — substantial reshape:
  - Pulls active shift via `useActiveShift`; "Open a shift first" guard on the Confirm button when no shift is open.
  - `METHOD_TO_TENDER` table maps FE method ids (`mada / visa / cash / stcpay / giftcard`) to canonical `(tender, tender_brand)` pairs. Mada and Visa both become `tender='card'` with the brand split preserved on `tender_brand`. STC Pay becomes `tender='wallet', tender_brand='stcpay'`. Cash and gift card stay tender-only.
  - `onConfirm` builds the canonical `/pos/sales` body (`shift_id`, `customer_id`, `channel='pos'`, `lines:[{product_id, qty, unit_price}]`, `payments:[{tender, tender_brand, amount, ref}]`). Each tender lands as its own payment row so reporting can split mada from visa.
- **`retail/pos/ReturnsWizardPage.jsx`** — submit body reshaped to `{reason, lines:[{product_id, qty, unit_price}], refund_payments:[{tender, amount}]}`. The FE method id maps to canonical tender via the same lookup.

### Tests

`backend/tests/Feature/PosWiringTest.php` *(new — 6 tests, 51 assertions)*:

- `sales_index_returns_items_kpis_envelope_with_joins` — full structure + branch/cashier eager-load + items_count + tender_brand surfaced as `payment`.
- `payment_provider_stub_persists_provider_ref_for_card_tender` — `STUB-VISA-XXXXXXXXX` provider_ref + `provider_response.provider='stub'` after a card sale.
- `payment_provider_skips_provider_ref_for_cash` — cash sales have `null` provider_ref (no network call).
- `unknown_tender_brand_is_rejected` — `TENDER_BRAND_INVALID` 422 on a brand not in `config('payments.brands')`.
- `returns_index_returns_envelope_with_kpis` — full structure + items count + KPI math after a real refund flow (`POST /pos/sales/{id}/return`).
- `payment_provider_binding_resolves_to_stub` — `app(PaymentProvider::class)` is `StubPaymentProvider` + the `charge` shape contract.

`backend/tests/Feature/PosTest.php` (pre-existing) keeps passing — it asserts on `data.id / subtotal / total / change_due` paths that survive the reshape.

`backend/app/Services/Pay/StubProvider.php` was deleted; its consumer (`PayController::createCharge`) updated to use the unified interface. Existing PayController tests pass.

**Backend suite: 601 → 607 tests / 4,807 → 4,897 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       329  →  334
unique_mock_only_endpoints  125  →  121
backend_routes              476  →  477   (+/pos/returns)
matched                     329  →  334
missing                       0  →    0
```

### What stayed demo

Nothing in the POS retail-side surface. The cashier path (PosPage / CheckoutModal / ReceiptComplete) is fully live; ReceiptsPage's list + filters bind to the real shape; ReturnsPage + ReturnsWizardPage post against the real refund route.

### Files touched

```
backend/database/migrations/
  2026_05_05_430001_add_tender_brand_and_provider_ref_to_pos_sale_payments.php  (new)
backend/app/Services/Pay/PaymentProvider.php       (rewritten — unified interface)
backend/app/Services/Pay/StubPaymentProvider.php   (new — implements all 4 methods)
backend/app/Services/Pay/StubProvider.php          (deleted — superseded by StubPaymentProvider)
backend/app/Providers/AppServiceProvider.php       (PaymentProvider binding seam)
backend/config/payments.php                         (new — provider config + brand whitelist)
backend/app/Models/PosSale.php                      (+ branch/cashier/customer relations)
backend/app/Models/PosReturn.php                    (+ sale/branch/cashier relations)
backend/app/Models/PosSalePayment.php               (+ tender_brand/provider_ref/provider_response fillable + casts)
backend/app/Services/Pos/PosService.php             (+ PaymentProvider DI; charge() per tender; brand validation)
backend/app/Http/Controllers/Pos/PosController.php  (salesIndex envelope rebuild;
                                                     returnsIndex new; shapeSale + new helper methods)
backend/app/Http/Controllers/Pay/PayController.php  (consume unified PaymentProvider interface)
backend/routes/api.php                              (+1 route /pos/returns)
backend/tests/Feature/PosWiringTest.php             (new, 6 tests)
app/src/modules/retail/retail.api.js                 (sales/returns/createSale/createReturn → real; +activeShift)
app/src/modules/retail/retail.hooks.js               (+useActiveShift; useCreateReturn(saleId, body))
app/src/modules/retail/pos/CheckoutModal.jsx         (METHOD_TO_TENDER map; canonical body shape; active-shift guard)
app/src/modules/retail/pos/ReturnsWizardPage.jsx     (canonical refund body shape)
```

### Follow-ups carried out of this batch

1. **Formalise `commsSend` as a `NotificationProvider` stub** — Batch 4's `customer_communications` send is effectively a stub already (writes the row, doesn't dispatch). Promote to a named `NotificationProvider` + `StubNotificationProvider` so the seam matches the new pattern. ~80 lines, separate small commit.
2. **Real PSP integration trigger** — when a Saudi PSP (mada-net, payfort, …) lands, `PAYMENTS_PROVIDER=<name>` flips and one new class implementing `PaymentProvider` is added. Every consumer (POS, Pay, future modules) gets the swap for free.

---

## Wiring batch 6 — Pay (9 pages live)

Sixth batch. The Pay sidebar group — Overview, Charges, Refunds, Disputes, Payouts, Settlements, Wallet, Terminals, Webhooks — wires live against the existing Pay backend, with the FE-friendly `{ items, kpis }` envelope added across every index endpoint. Fee + provider_ref tracking on charges already comes through the unified `PaymentProvider` seam from Batch 5.

### Backend (~150 lines bundled silently)

`PayController` upgraded:

- **`chargesIndex`** — paginated `{ items, kpis, meta }` envelope. Validates `?status` + `?gateway` filters. KPIs (`captured / authorized / failed / gross_today`) span the full tenant set.
- **`refundsIndex`** — `{ items, kpis }`. KPIs: `total / succeeded / failed / value_30d`.
- **`disputesIndex`** — `{ items, kpis }`. KPIs: `total / open / won / lost / value` (open-dispute value).
- **`payoutsIndex`** — `{ items, kpis }`. KPIs: `total / scheduled / paid / failed / paid_total`.
- **`settlementsIndex`** — `{ items, kpis }` with `charge_count` per row + total/gross/net/fees rollup.
- **`terminalsIndex`** — `{ items, kpis }` with `total / online / offline / pending / warning` counts.
- **`outboundIndex`** (webhooks) — `{ items, kpis }` with `total / active / failures` rollup.
- **`walletShow`** — extended with FE-friendly `available / pending / reserved / next_payout` aliases. `available` = `pay_wallets.balance`; `pending` = captured charges minus settled (`PaySettlement::sum('net')`); `reserved` = 0 (no schema for it yet); `next_payout` = soonest scheduled `PayPayout`.

`PayRefund` model gains the missing `charge()` belongsTo relation (the existing `with('charge:id,amount')` eager-load was using a relation that didn't exist).

### Frontend

**`pay/pay.api.js`** — switched 9 endpoints from `mockOnlyRequest` to `realRequest`. Header comment redocuments the live/mock split. Three flows stay mock because the BE doesn't ship them: `/pay/channels` (FE Overview channel breakdown — 4 hard-coded channel cards), `POST /pay/disputes/{id}/respond` (no respond endpoint), `POST /pay/terminals/{id}/heartbeat` (no heartbeat endpoint).

`createRefund` body shape adapter — FE calls `mutate({ charge, amount, reason })` and the api helper translates to `POST /pay/charges/{charge}/refund` with `{ amount, reason }` body. No FE page change needed.

### Tests

`backend/tests/Feature/PayWiringTest.php` *(new — 9 tests, 84 assertions)*:
- charges envelope + KPIs (captured/authorized/failed/gross_today) + status filter
- refunds, disputes, payouts, settlements, terminals, outbound webhooks envelopes
- wallet returns available/pending/reserved/next_payout

**Backend suite: 607 → 616 tests / 4,897 → 4,981 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       334  →  343
unique_mock_only_endpoints  121  →  112
backend_routes              477  →  477
matched                     334  →  343
missing                       0  →    0
```

### What stayed demo

- **Pay · Overview channel cards** — `/pay/channels` derived aggregation. 4 channels hard-coded FE-side. To wire live we'd need a backend `channelsIndex` that buckets `pay_charges` by `provider`. Logged as a small follow-up.
- **POST /pay/disputes/{id}/respond** — no backend endpoint. UI button still functional via mock.
- **POST /pay/terminals/{id}/heartbeat** — no backend endpoint.

### Files touched

```
backend/app/Http/Controllers/Pay/PayController.php   (8 index methods upgraded to {items, kpis} envelope)
backend/app/Models/PayRefund.php                      (+ charge() relation)
backend/tests/Feature/PayWiringTest.php               (new, 9 tests)
app/src/modules/pay/pay.api.js                        (mock → real for 9 endpoints; channels/respond/heartbeat stay mock)
```

---

## Wiring batch 7 — Retail Zero-Friction (8 pages)

Seventh batch. The Retail ZF tab — Overview, Learned, Shelf, Reconciliation, Jobs, ZATCA, Settings, Flow — wires live with envelope upgrades on the existing `/zf/*` endpoints.

### Backend (~80 lines bundled silently)

`ZfController` upgraded:
- **`overview`** — adds an `activity` slice (last 8 jobs across all kinds) so the OverviewPage can render its activity feed without a second round-trip.
- **`jobsIndex`** — `{ items, kpis, meta }` envelope. KPIs: `total / queued / running / done / failed`. Filter params `?status` + `?kind`.
- **`learnedIndex`** — `{ items, kpis }`. KPIs: `total / high_conf / low_conf` (high = ≥80, low = <50).
- **`shelfScansIndex`** — `{ items, kpis }`. KPIs: `total / this_week`. Items now expose `branch_id`.
- **`zatca`** — `{ items, kpis }`. KPIs: `total / submitted / failed / cleared`.
- `reconciliation` already returned `{ items, kpis }` — no change.
- `settingsShow` / `settingsUpdate` already shaped correctly.

### Frontend

`retail/retail.api.js` — switched 7 ZF endpoints from `mockOnlyRequest` to `realRequest` against `/zf/*` (path adapter). New `zfSettings` + `zfUpdateSettings` helpers. `patchLearned` resolves to `POST /zf/learned` (upsert) until per-row updates land. `/zf/ocr` stays demo (no backend; OcrPage retains DemoBanner).

### Tests

`backend/tests/Feature/ZfWiringTest.php` *(new — 4 tests, 24 assertions)*: overview KPIs+activity, jobs envelope+filter, learned confidence buckets, shelf this-week KPI.

Pre-existing `OwnerEcomZfTest` keeps passing — it asserts on `/zf/jobs` POST/run + `/zf/settings`, not the index shape.

**Backend suite: 616 → 620 tests / 4,981 → 5,005 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       343  →  352
unique_mock_only_endpoints  112  →  105
backend_routes              477  →  477
matched                     343  →  352
missing                       0  →    0
```

### What stayed demo

- **Retail · ZF · OCR** — `/retail/zf/ocr` has no backend equivalent. OcrPage retains its DemoBanner.

### Files touched

```
backend/app/Http/Controllers/Zf/ZfController.php     (5 endpoints upgraded; overview adds activity)
backend/tests/Feature/ZfWiringTest.php                (new, 4 tests)
app/src/modules/retail/retail.api.js                   (7 endpoints mock → real /zf/*)
```

---

## Wiring batch 8 — Retail Growth (4 pages)

Eighth batch. Loyalty / Gift cards / Bundles / Layaways wire live; Campaigns stays demo for this batch (separate sub-tab; the BE has `/campaigns` but the FE expects a richer shape that warrants its own pass).

### Backend (~80 lines bundled silently)

`GrowthController` upgraded:
- **`loyaltyIndex`** — `{ items, kpis }`. KPIs: `total / active / points_issued / members`.
- **`giftCardsIndex`** — paginated `{ items, kpis, meta }`. KPIs: `total / active / expired / outstanding_sum` (sum of active card balances).
- **`bundlesIndex`** — `{ items, kpis }` with `total / active`.
- **`layawaysIndex`** — `{ items, kpis }` with `total / open / completed / outstanding_value`.

### Frontend

`retail/retail.api.js` Growth block — switched from `mockOnlyRequest` to `realRequest` against the canonical paths (`/loyalty/programs`, `/gift-cards`, `/bundles`, `/layaways`). Verb adapters:
- **`redeemGiftCard`** — FE used `PATCH /retail/growth/gift-cards/{code}`; now `POST /gift-cards/{code}/redeem`.
- **`payLayaway`** — FE used `PATCH /retail/growth/layaways/{id}`; now `POST /layaways/{id}/pay`.

### Tests

`backend/tests/Feature/GrowthWiringTest.php` *(new — 4 tests, 24 assertions)*: each index endpoint's envelope + KPI math verified.

`CustomersGrowthTest` keeps passing (it asserts on POST/award/redeem/pay paths, not the index shapes).

**Backend suite: 620 → 624 tests / 5,005 → 5,062 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       352  →  360
unique_mock_only_endpoints  105  →  97
backend_routes              477  →  477
matched                     352  →  360
missing                       0  →    0
```

### What stayed demo

- **Retail · Growth · Campaigns** — `/retail/growth/campaigns` (mock). The BE has `/campaigns` + `/campaigns/{id}/run` but the FE renders a richer marketing-automation shape that warrants its own batch.

### Files touched

```
backend/app/Http/Controllers/Growth/GrowthController.php  (4 endpoints upgraded)
backend/tests/Feature/GrowthWiringTest.php                 (new, 4 tests)
app/src/modules/retail/retail.api.js                        (8 Growth endpoints mock → real; verb adapters for redeem + pay)
```

---

## Wiring batch 10 — Dine Menu / Modifiers / Combos (FULL — 2 schema migrations)

Tenth batch of the wiring plan. Three foundational Dine pages move off mock and onto the real backend. Same Option C rule as Batches 4 and 5 — the FE was sketched against a richer mock fixture domain than the BE shipped, so we bring the BE up to the FE rather than amputate FE features. Two `menu_items` + `combos` schema migrations land alongside controller upgrades for the three index endpoints.

**Scoping check:** the plan estimated this batch as a 280-line composite (1 FE call → 3 BE calls for the menu). It came in at ~830 lines because of the same domain-model gap pattern. Stopped, presented five options (a/b/c/d/e), user locked **(c) FULL**.

### Schema migrations (2)

`menu_items`:
- `cost`        decimal(12,2)   COGS per item, independent of recipe-derived cost (so menu engineering can pin a static margin number).
- `station`     string(32)      kitchen station label (`grill / cold / dessert / bar / ...`). Used by `MenuPage`'s station filter. KDS routing still happens by category via `kds_stations.menu_categories`; this column is purely for the merchandiser view.
- `prep_min`    smallInt        expected prep time in minutes; rendered on the row, will inform KDS time-to-ready estimates in Batch 11.
- `tags`        json            free-form tags (`['signature','spicy','vegan']`) rendered as gold pills.
- `available`   boolean         soft-86 toggle, distinct from `is_active`. `is_active` is the tenant-level enable; `available` is what the cashier flips for a stock-out hour.

`combos`:
- `tag`           string(16)    one of `family|lunch|ramadan|dessert|signature|other`. FE keys badge tone off this discriminator.
- `starts_at`     string(8)     'HH:MM' window during which the combo is sellable.
- `ends_at`       string(8)     same.
- `regular_price` decimal(12,2) sum-of-components reference the FE shows struck-through.
- `discount_pct`  smallInt(0-100) cached display value. **Auto-derived on read** when the row was inserted with `regular_price` but no `discount_pct`: `round((1 - price/regular_price) * 100)`. This keeps thin POSTs honest without needing the FE to compute the number.

The pivot table `menu_item_modifier_groups` already existed (Phase 8 dine migration) — the FE's "applies to: Curry, Tikka, Biryani" view binds straight to it. New: a small `MenuItemModifierGroupPivot` Eloquent pivot model that fills the ULID primary key on `attach()` / `sync()` (the table was created with `ulid('id')->primary()` but Eloquent's vanilla `belongsToMany` doesn't fill it on insert — sync would have hit a NOT NULL constraint).

### Backend controller upgrades

`DineController` rewrites:

- **`menusIndex`** — returns `{ menus[tree], items[flat], kpis }`. The nested `menus[]` keeps the legacy shape for back-compat; the new flat `items[]` carries the merchandiser fields plus a `category` label so the FE list filters work without an additional roundtrip. KPIs: `total / available / stations / avg_margin`. Margins computed across all items where `price > 0`.
- **`modifierGroupsIndex`** — `{ items, kpis }`. Each row carries `kind` (derived: `single` if `max_select == 1`, `multi` otherwise), `required` (alias of `is_required` for FE convenience), `options[]` (alias of `modifiers[]`), and `applies_to[]` (menu_item_ids from the pivot). KPIs: `total / single / multi / required`.
- **`combosIndex`** — `{ items, kpis }`. `shapeCombo()` exposes both legacy aliases (`starts`, `ends`, `regular`, `active`, `items`) and canonical column names (`starts_at`, `ends_at`, `regular_price`, `is_active`, `components`). Auto-derives `discount_pct` from `regular_price` ÷ `price` when the merchant didn't pin one. KPIs: `total / active / avg_discount`.
- **`combosStore`** — accepts the new fields with validation (`tag` whitelist, `discount_pct` 0-100, `regular_price` ≥ 0).
- **`combosPatch`** *(new)* — `PATCH /dine/combos/{id}` for the inline active toggle. Cross-tenant guard via `guardSameTenant`. Accepts both `active` (FE alias) and `is_active` (canonical).
- **`itemsStore`** — extended validation for `cost / station / prep_min / tags / available`.
- **`modifierGroupAppliesTo`** *(new)* — `POST /dine/modifier-groups/{group}/applies-to` body `{ menu_item_ids: [...] }`. Replace semantics (sync, not append). Cross-tenant guard.

### Tests

`backend/tests/Feature/DineMenuModifiersCombosWiringTest.php` *(new — 8 tests, 101 assertions)*:

- `test_menus_index_returns_flat_items_and_kpis` — envelope + KPI math (avg margin, station count).
- `test_items_store_persists_new_fields` — `cost / station / prep_min / tags / available` round-trip through the POST handler.
- `test_modifier_groups_index_derives_kind_and_applies_to` — pivot membership + derived `kind`.
- `test_modifier_group_applies_to_replace` — replace semantics: A+B → C, not A+B+C.
- `test_combos_index_envelope_with_auto_derived_discount` — auto-derives `discount_pct=25` from price=60/regular=80.
- `test_combos_store_accepts_new_fields` — POST round-trip.
- `test_combos_patch_active_toggle` — both `active` (FE alias) and `is_active` (canonical) flip the row.
- `test_cross_tenant_combo_patch_returns_404` — tenant isolation guard.

E2E in `WiringSmokeE2ETest::test_dine_menu_modifiers_combos_full_lifecycle` (43 assertions) — full lifecycle: build menu (2 categories, 3 items with merchandiser fields), wire 2 modifier groups with applies_to pivot, create a combo with auto-derived discount, toggle active. Validates `kind` derivation, `applies_to` round-trip, KPI math, FE legacy aliases (`starts/ends/regular/active`), and tenant isolation indirectly (everything created via the auth'd request, no cross-tenant fixture leaks).

`tests/Feature/DineTest::test_menus_categories_items_create_and_list` updated — the response root is now `data.menus.0.categories.0.items` (was `data.0.categories.0.items`) since the envelope is `{ menus, items, kpis }` not `[]`.

**Backend suite: 624 → 633 tests / 5,062 → 5,206 assertions, all green.**

### Frontend

`app/src/modules/dine/dine.api.js`:
- File-header comment rewritten — Batch 10 makes `menu / modifiers / combos` live; the rest stays mock until later batches.
- `menu()` calls `realRequest.get('/dine/menus')`. The unwrapped backend payload is `{ menus, items, kpis }` and `MenuPage` already binds to `q.data?.items` + `q.data?.kpis`, so no FE page changes are needed.
- `patchMenuItem(id, body)` switches to `realRequest.patch('/dine/menu-items/{id}')` (the legacy `/dine/menu/{id}` path didn't exist on the backend).
- `modifiers()` calls `realRequest.get('/dine/modifier-groups')` — `ModifiersPage` already binds to `q.data?.items` with `g.kind / g.required / g.options / g.applies_to`, all of which the BE now emits.
- `combos()` calls `realRequest.get('/dine/combos')` — `CombosPage` binds `c.tag / c.starts / c.ends / c.regular / c.discount_pct / c.active / c.items`, all live.
- `patchCombo(id, body)` calls `realRequest.patch('/dine/combos/{id}')` — backs the inline `Disable / Enable` button.
- `patchModifierAppliesTo(id, body)` *(new)* — `realRequest.post('/dine/modifier-groups/{id}/applies-to')` for the (future) FE editor that lets a manager pick which items a group applies to. Wired into the API surface so a future page doesn't need its own batch for the verb.

No changes to `MenuPage.jsx`, `ModifiersPage.jsx`, `CombosPage.jsx` — the BE shape was tailored to match what the pages already render.

### Endpoint diff

```
unique_live_endpoints       360  →  363   (+ /dine/menus, /dine/modifier-groups, /dine/combos)
unique_mock_only_endpoints  97   →   94   (− /dine/menu, /dine/categories, /dine/modifiers; - /dine/combos's mock counterpart)
backend_routes              477  →  479   (+ /dine/combos/{combo} PATCH, /dine/modifier-groups/{id}/applies-to POST)
```

### What stayed demo (still mock for later batches)

- `categories()` (returns the categories list standalone) — the BE has the data nested inside `/dine/menus`; the FE only consumes this from a couple of order-builder views which are themselves still mock until Batch 11.
- All other Dine routes (orders, KDS, tables, reservations, waitlist, aggregator inbox, deliveries, kiosk, QR, ramadan profiles, reports overview).

### Files touched

```
backend/database/migrations/2026_05_05_440001_extend_menu_items_for_dine_menu_page.php  (new)
backend/database/migrations/2026_05_05_440002_extend_combos_for_dine_combos_page.php    (new)
backend/app/Models/MenuItem.php                                                         (5 fillable + casts; category() relation)
backend/app/Models/Combo.php                                                            (5 fillable + casts)
backend/app/Models/ModifierGroup.php                                                    (menuItems() pivot relation)
backend/app/Models/MenuItemModifierGroupPivot.php                                       (new — ULID-aware pivot)
backend/app/Http/Controllers/Dine/DineController.php                                    (3 index rewrites + 2 new endpoints)
backend/routes/api.php                                                                  (+ PATCH /dine/combos/{combo}, POST /dine/modifier-groups/.../applies-to)
backend/tests/Feature/DineMenuModifiersCombosWiringTest.php                             (new, 8 tests)
backend/tests/Feature/WiringSmokeE2ETest.php                                            (+ Dine lifecycle test, helper imports)
backend/tests/Feature/DineTest.php                                                      (envelope path update)
app/src/modules/dine/dine.api.js                                                        (5 endpoints mock → real; new applies-to verb)
```

### Follow-ups (separate scope, not blocking the merge)

- No demo seed data for menus/combos/modifiers ships with `migrate:fresh --seed`. First sign-in to a fresh tenant shows empty Menu/Modifiers/Combos pages with proper empty-state styling. Adding a `seedAcmeDine` (~120 lines) is a follow-up if smoke-testing wants live data instead of starting from scratch.
- Batch 11 will hit `categories()` (it's used by the order builder), so that switches mock→live there.

---

## Wiring batch 11 — Dine Floor / Orders / KDS (FULL + per-line — 1 schema migration)

Eleventh batch of the wiring plan. Three of the most-trafficked Dine pages move off mock and onto the real backend, including a brand new aggregated KDS endpoint and per-line state tracking on KDS tickets.

**Scoping check:** the plan estimated this as a 360-line composite. It came in at ~1,400 lines because the BE shape was wider than the menu wiring batch. Stopped, presented five options (a/b/c/d/e). User locked **(d) FULL + per-line tracking**.

### Schema migration (1)

`kds_ticket_lines` *(new table)*:
- `id` ulid PK; `ticket_id` FK → `kds_tickets`; `menu_item_id` FK → `menu_items`
- `qty` decimal(10,2); `modifiers` json; `notes` string(200)
- **`status`** string(16) — `queued | firing | ready | bumped`. The KDSPage shows a coloured dot per line keyed off this column.
- `started_at` / `ready_at` timestamps; `timestamps()`; index on `(ticket_id, status)`.

The existing `kds_tickets.lines` JSON column stays in place for back-compat with legacy readers. New writes populate **both** the JSON column and the new per-line rows. New reads use the per-line rows. A future cleanup batch can drop the JSON column once nothing reads from it.

### Backend controller upgrades

`DineController` rewrites:

- **`tablesIndex`** — was a flat list `[{id,code,section,capacity,status}]`. Now `{ items, kpis }` with active-`dine_order` joins. Each row carries `server` (waiter name), `party` (from `meta.party_size`), `guests` (from `meta.guest_names`), `opened` (HH:MM of order create), `total` (live order total), and `order_id`. KPIs: `total / occupied / available / utilization (% occupied) / live_revenue`. Eager-loads waiters in one query — no N+1.
- **`ordersIndex`** — was `{data, meta}` with paginate envelope. Now `{ items, kpis, meta }` with eager joins (`waiter`, `table`, `lines.menuItem`). Status vocabulary translation in both directions:
  ```
  FE → BE filter:        BE → FE response:
  queued      → open     open            → queued
  in_progress → sent_to_kitchen   sent_to_kitchen → in_progress
  ready       → ready              ready           → ready
  served      → served|paid        served|paid     → served
  cancelled   → cancelled          cancelled       → cancelled
  ```
  Channel/kind translation:
  ```
  FE kind     → BE channel        BE channel        → FE kind
  dine_in     → dine_in           dine_in           → dine_in
  delivery    → delivery|aggregator   delivery|aggregator → delivery
  pickup      → takeaway|qr|kiosk     takeaway|qr|kiosk   → pickup
  ```
  `age_min` derived server-side from `created_at`. `partner` exposed from `meta.partner` only when channel is `aggregator`. KPIs: `total / dine_in / delivery / pickup / revenue` (today's slice).
- **`ordersFire`** *(new)* — `POST /dine/orders/{id}/fire`. Idempotent: open → sent_to_kitchen, no-op when already past open. Mirrors the FE `useFireOrder` mutation.
- **`ordersServe`** *(new)* — `POST /dine/orders/{id}/serve`. Idempotent: ready or sent_to_kitchen → served. Mirrors `useServeOrder`.
- **`ordersStatus`** — extended to accept FE-vocabulary aliases (`queued`, `in_progress`) and translate them to BE values before delegating to `DineOrderService::transitionStatus`. The service still only ever sees BE vocabulary internally.
- **`kdsAggregate`** *(new — `GET /dine/kds`)* — single composite endpoint. Returns `{ items, menu, kpis }`:
  - `items[]` — every active ticket (status in `queued | cooking | ready`) joined with its order, table, waiter, station; status mapped to FE vocabulary (`in_progress | ready | late`); `late` derived as `in_progress AND age_min > 20`; `items[]` per ticket renders the per-line rows from `kds_ticket_lines` with their own `firing|ready|queued|bumped` state.
  - `menu[]` — bilingual name lookup for every menu_item referenced by the ticket lines (FE doesn't need a second roundtrip and stays bilingual).
  - `kpis` — `active / ready / late / avg_ticket_min` (avg computed across tickets that closed today, gated by `started_at` and `ready_at`).
- **`kdsTicketAdvance`** — extended to cascade ticket-level status changes down to the per-line rows. Advancing a ticket to `cooking` flips every queued line to `firing` (and timestamps `started_at`); advancing to `ready` flips remaining `queued|firing` lines to `ready`. Keeps the KDS dot legend honest without forcing the FE to bump every line individually.
- **`kdsLineAdvance`** *(new — `PATCH /dine/kds/lines/{line}`)* — flip a single ticket-line status. Used when the cashier marks "rice ready, lamb still firing". Cross-tenant guard via the ticket → order → company chain.

### Service updates

`DineOrderService::emitKdsTickets` — when a new dine order is created, the service now writes both the legacy JSON snapshot AND a `KdsTicketLine` row per order line. Default status is `queued`.

### Models

- **`KdsTicket`** gains `order()`, `station()`, `ticketLines()` relations.
- **`KdsTicketLine`** *(new)* — `STATUSES` constant, fillable, casts, `ticket()` and `menuItem()` relations.
- **`DineOrder`** gains `waiter()`, `table()`, `branch()`, `customer()` relations.
- **`DineOrderLine`** gains `order()`, `menuItem()` relations.

### Tests

`backend/tests/Feature/DineFloorOrdersKdsWiringTest.php` *(new — 7 tests, 137 assertions)*:

- `test_tables_index_envelope_with_active_order_joins` — server/party/guests/total surface from the joined active order.
- `test_orders_index_envelope_status_kind_translation` — both directions of the status + kind vocab table.
- `test_orders_fire_serve_idempotent_wrappers` — fire moves open→sent_to_kitchen; calling fire twice is a no-op; serve advances correctly.
- `test_kds_aggregate_returns_items_menu_kpis` — single-call composite returns ticket + menu + KPIs all wired.
- `test_kds_ticket_advance_cascades_to_lines` — ticket → `cooking` cascades all queued lines to `firing` with started_at; → `ready` flips remaining to `ready`.
- `test_kds_line_advance_individual` — per-line bump flow (firing then ready) with timestamp side-effects.
- `test_cross_tenant_kds_line_returns_404` — tenant isolation guard for the per-line endpoint.

E2E in `WiringSmokeE2ETest::test_dine_floor_orders_kds_full_lifecycle` (44 assertions) — full round-trip through the real `DineOrderService`: create order → KDS auto-emits ticket + lines → tables index reflects occupied with live total → orders index emits FE vocab + KPIs → KDS aggregate returns single composite → ticket cooks (line cascade) → per-line bump → fire idempotent → serve → **paid (DineOrderPoster runs, JE balanced via `assertJournalsBalance`)** → ticket retires from active KDS.

**Backend suite: 633 → 640 tests / 5,206 → 5,343 assertions, all green.**

### Frontend

`app/src/modules/dine/dine.api.js`:
- File-header comment rewritten — Batch 11 makes Floor/Orders/KDS live.
- `tables()` → `realRequest.get('/dine/tables')`. FloorPage already binds `q.data?.items / kpis` so no page changes.
- `orders(params)` → `realRequest.get('/dine/orders', params)`. OrdersPage filter values pass through unchanged (BE understands FE vocab).
- `fireOrder(id)` / `serveOrder(id)` → `realRequest.post('/dine/orders/{id}/fire' or '/serve')`.
- `kds(params)` → `realRequest.get('/dine/kds')`. KDSPage already binds `q.data?.items / menu / kpis`. The 15s polling continues to work on the new endpoint.
- `markItemReady(lineId, status='ready')` — signature changed: was keyed by `(orderId, itemId)` against a never-existed mock route, now uses the new `kds_ticket_lines.id` and the real `PATCH /dine/kds/lines/{id}` endpoint.
- `bumpTicket(ticketId, status)` *(new)* — wraps `PATCH /dine/kds/tickets/{id}`. Backs the FE-only "Bump" button on each KDS card.

`app/src/modules/dine/dine.hooks.js`:
- `useMarkItemReady` updated for the `{ lineId, status }` signature.
- `useBumpTicket` *(new)* — for the ticket-level bump button.

No changes to `FloorPage.jsx`, `OrdersPage.jsx`, `KDSPage.jsx` — the BE shape was tailored to match what the pages already render.

### Endpoint diff

```
unique_live_endpoints       363  →  368   (+ /dine/tables, /dine/orders index live; /dine/orders/{id}/fire, /dine/orders/{id}/serve, /dine/kds, /dine/kds/lines/{id} all new)
unique_mock_only_endpoints   94  →   89
backend_routes              479  →  484
```

### What stayed demo (later batches)

- `createOrder()` — the order-builder UI still runs against the in-memory mock pending Batch 11b (the FE order builder needs the new menu/categories endpoints from Batch 10 wired into its picker before it can roundtrip cleanly).
- `categories()` — still mock; nested already inside `/dine/menus`. Will switch in 11b.
- All Batch 12 surfaces (reservations, waitlist, aggregator inbox, deliveries, kiosk, QR, ramadan, reports overview).

### Files touched

```
backend/database/migrations/2026_05_05_440003_create_kds_ticket_lines.php  (new)
backend/app/Models/KdsTicketLine.php                                       (new)
backend/app/Models/KdsTicket.php                                           (3 relations)
backend/app/Models/DineOrder.php                                           (4 relations)
backend/app/Models/DineOrderLine.php                                       (2 relations)
backend/app/Services/Dine/DineOrderService.php                             (per-line write on emitKdsTickets)
backend/app/Http/Controllers/Dine/DineController.php                       (5 endpoints upgraded + 4 new)
backend/routes/api.php                                                     (+ 4 routes: fire/serve/kds aggregate/kds line patch)
backend/tests/Feature/DineFloorOrdersKdsWiringTest.php                     (new, 7 tests)
backend/tests/Feature/WiringSmokeE2ETest.php                               (+ Batch 11 lifecycle, helper imports)
app/src/modules/dine/dine.api.js                                           (5 endpoints mock → real; 1 verb signature change; 1 new verb)
app/src/modules/dine/dine.hooks.js                                         (useMarkItemReady signature update; new useBumpTicket)
```

### Follow-ups

- Batch 11b: wire the FE order-builder (`createOrder`) once the menu picker reshape is decided. Splits naturally because the order-builder is a deeper FE-side composition than a simple data binding.
- KDS line-level "Mark ready" button isn't currently wired in the FE (the button has no onClick). Adding the click handler is a one-liner once design sign-off lands.
- `kds_tickets.lines` JSON column can be dropped in a cleanup batch once nothing reads from it. (`kdsTickets` legacy read endpoint still does.)

---

## Wiring batch 12 — Dine Reservations / Waitlist / Aggregator-inbox / Reports (FULL + dedicated table — 2 schema migrations + AggregatorProvider stub seam)

Twelfth batch of the wiring plan. Last four mock-pinned Dine pages move off mock and onto the real backend. **Every Dine surface that has a backend route is now live.** The aggregator integration drops in as a textbook application of the Batch 5 third-party stub seam pattern — same primitive, second instance.

**Scoping check:** the plan listed this as ~280 lines of envelope adapters. It came in at ~1,400 lines because Aggregator alone is a domain that didn't exist yet on the backend. Stopped, presented five options (a/b/c/d/e). User locked **(d) FULL + dedicated aggregator_orders table**.

### Schema migrations (2)

`aggregator_orders` *(new table)*:
- ulid PK; `company_id`, `branch_id`, optional `aggregator_id` (FK to partner credentials), optional `dine_order_id` (back-fill on accept).
- `partner` (talabat / jahez / hungerstation / mrsool); `partner_ref` (partner-issued order #); `topic` (pickup / delivery ETA); `prep_min`; `total`.
- `status` (`pending | accepted | rejected | cancelled`); `reason` (text); `raw_payload` (json — full webhook body for audit/replay); `received_at`; `decided_at`.
- Indexed on `(company_id, status)` and `(partner, partner_ref)`.

The inbox lifecycle is **deliberately separate** from the kitchen lifecycle: pending → accepted | rejected | cancelled is conceptually different from open → sent_to_kitchen → … → paid. On accept the controller materialises a `dine_orders` row (channel='aggregator', status='open') and links it via `dine_order_id` so the join is explicit.

`waitlist_entries` extension:
- `notified_at` timestamp (nullable). The FE renders three pill states (`waiting / notified / seated`); the BE enum had only two (`waiting / seated / left`). We carry the notification as a timestamp instead of an additional enum value so the underlying status stays accurate AND we get SLA timing for free.

### Third-party stub seam — AggregatorProvider

Same primitive as Batch 5's `PaymentProvider` (the rule retroactively applied across the codebase from that batch on):

- **`AggregatorProvider`** *(interface)* — `verifyWebhook(payload, signature)`, `acceptOrder(partner, partner_ref)`, `rejectOrder(partner, partner_ref, reason)`, `name()`.
- **`StubAggregatorProvider`** *(default)* — permissive `verifyWebhook` so test traffic flows through; `accept`/`reject` produce `STUB-ACCEPT-…` / `STUB-REJECT-…` acknowledgements that surface clearly in audit logs as simulated round-trips.
- **`config/aggregators.php`** — `provider` (env `DINE_AGGREGATOR_PROVIDER`, default `stub`) + `partners` whitelist (validation source for the `partner` column).
- **`AppServiceProvider`** — binding pattern matches `PaymentProvider`. Real partner integrations land as new classes (`TalabatProvider`, `JahezProvider`, `HungerStationProvider`) without touching consuming code.

The merchant-facing inbox surface (`/dine/aggregator-inbox`) is identical regardless of which provider is wired. Real partner webhooks will land at partner-specific paths (e.g. `/webhooks/aggregator/jahez`) in a follow-up commit and route through `verifyWebhook` before creating an `aggregator_orders` row.

### Backend controller upgrades

`DineController` rewrites:

- **`reservationsIndex`** — `{ items, kpis }` envelope. Status vocab translation: `booked → pending`, `seated → confirmed`, `cancelled / noshow → cancelled`, `completed → confirmed` (the slot ran successfully). `tonight` KPI counts active reservations starting in the 17:00-23:59 window.
- **`reservationsConfirm`** *(new — `POST /dine/reservations/{id}/confirm`)* — `booked → seated`. Refuses to confirm a cancelled reservation with `STATUS_INVALID/422`.
- **`reservationsCancel`** *(new)* — flips status to `cancelled` regardless of prior state.
- **`reservationsStore`** — accepts FE form aliases (`size` for party_size, `when` for starts_at). Falls back to the user's active branch when `branch_id` is omitted.
- **`waitlistIndex`** — `{ items, kpis }` envelope with the derived display state described above. `avg_quoted` runs only on `waiting` rows (notified + seated already passed the SLA gate). `left` rows filtered out — they retired.
- **`waitlistNotify`** *(new)* — fires `notified_at = now()` if not set. Refuses to notify a non-waiting entry. (Future Notification provider stub will fan out SMS/Push from this same handler.)
- **`waitlistSeat`** *(new)* — flips `status` to `seated`.
- **`waitlistStore`** — accepts FE aliases (`size`, `quoted_min`); falls back to the active branch.
- **`aggregatorInboxIndex`** *(new — `GET /dine/aggregator-inbox`)* — `{ items, kpis }` with partner + status filters. KPIs run on TODAY's slice.
- **`aggregatorInboxAccept`** *(new — `POST /dine/aggregator-inbox/{id}/accept`)* — atomic transaction: flips inbox row → `accepted`, creates a `dine_orders` row with `channel='aggregator'` and `meta.partner / partner_ref / topic / prep_min`, back-fills `dine_order_id`, then pushes the accept ack to the partner via `AggregatorProvider::acceptOrder`. **Idempotent** — calling twice returns the same dine_order_id.
- **`aggregatorInboxReject`** *(new)* — flips to `rejected` + reason; pushes ack via `AggregatorProvider::rejectOrder`.
- **`aggregatorInboxStubArrival`** *(new — `POST /dine/aggregator-inbox/stub-arrival`)* — only available when the provider binding resolves to `stub`. Creates a pending `aggregator_orders` row mimicking a partner webhook. Used by the smoke walkthrough and tests so the merchant can see the full flow without real partner traffic.
- **`reportsOverview`** *(new — `GET /dine/reports/overview`)* — comprehensive single-call dashboard for the FE ReportsPage. Aggregates across `dine_orders`, `dine_order_lines`, `kds_tickets`, `kds_ticket_lines`. Shape:
  - `period: 'today'`
  - `kpis`: `covers_today / avg_ticket / revenue_today / food_cost_pct / avg_table_min / avg_kds_min / cancellations / delivery_share_pct` (8 fields).
  - `by_hour[]`: pre-fills 24 buckets so the FE bar chart has zero-bars for empty hours instead of squishy variable widths.
  - `top_items[]`: top 5 menu items by revenue today (excluding cancelled lines), with bilingual names embedded.
  - `stations[]`: per-KDS-station prep_avg_min + late_pct (late = ticket prep > 20 min).

  Food cost % computed from `sum(line.qty * menu_item.cost) / revenue` so the new `menu_items.cost` column from Batch 10 carries through to the dashboard.

### Tests

`backend/tests/Feature/DineReservationsWaitlistAggregatorReportsWiringTest.php` *(new — 9 tests, 131 assertions)*:

- `test_aggregator_provider_binding_is_stub` — DI seam wires correctly.
- `test_reservations_index_envelope_with_status_translation` — vocab round-trip + tonight KPI window.
- `test_reservations_confirm_and_cancel` — verb flow + idempotent guard against confirming a cancelled reservation.
- `test_reservations_store_accepts_fe_aliases` — FE form aliases (`size`, `when`) round-trip correctly.
- `test_waitlist_index_derives_notified_state` — three-state derivation from `status` + `notified_at`; `left` rows filtered.
- `test_waitlist_notify_and_seat` — verbs flip the right columns; `notify` keeps status=waiting.
- `test_aggregator_inbox_full_lifecycle` — stub arrival → index → accept → dine_order row created → idempotent re-accept → reject with reason.
- `test_reports_overview_aggregations` — covers/revenue/food cost%/delivery share %/by_hour buckets/top items by revenue/station prep avg + late %.
- `test_cross_tenant_aggregator_accept_returns_404` — tenant isolation.

E2E in `WiringSmokeE2ETest::test_dine_reservations_waitlist_aggregator_reports_lifecycle` (44 assertions): full lifecycle through both stub seam (PaymentProvider for the eventual paid + AggregatorProvider for the inbox) — reservation create + confirm; waitlist add + notify + seat; aggregator stub arrival → accept → resulting dine_order shows up in `/dine/orders` with FE vocab `kind=delivery / partner=jahez`; reject with reason; reports overview returns all aggregations including the 138-SAR aggregator order surfacing as 100% delivery share.

Plus `test_aggregator_provider_binding_is_stub` as a cross-batch DI smoke check (matches the existing `test_payment_provider_binding_is_stub`).

**Backend suite: 640 → 658 tests / 5,343 → 5,617 assertions, all green.**

### Frontend

`app/src/modules/dine/dine.api.js`:
- File-header comment rewritten — Batches 10/11/12 now cover every Dine surface with a backend route. Remaining mocks (`/dine/deliveries`, `/dine/kiosk-sessions`, `/dine/qr-flows`, `/dine/ramadan-*`) are roadmap items with no backend at all.
- `reservations()`, `createReservation()`, `confirmReservation(id)`, `cancelReservation(id)` → `realRequest`.
- `waitlist()`, `addWaitlist()`, `notifyWaitlist(id)`, `seatWaitlist(id)` → `realRequest`.
- `aggregatorInbox(params)`, `acceptAggregator(id)`, `rejectAggregator(id, body)` → `realRequest`.
- `stubAggregatorArrival(body)` *(new)* — exposes the demo entry point so the smoke walkthrough can simulate a partner arrival.
- `reportsOverview()` → `realRequest`.

No changes to `ReservationsPage.jsx`, `WaitlistPage.jsx`, `AggregatorPage.jsx`, `ReportsPage.jsx` — the BE shape was tailored to match what the pages already render. (Note: `AggregatorPage` filter posts `partner=mrsool`, which the BE accepts via `config('aggregators.partners')`.)

### Endpoint diff

```
unique_live_endpoints       368  →  377   (reservations + 2 verbs, waitlist + 2 verbs,
                                            aggregator-inbox index + accept + reject + stub-arrival,
                                            reports/overview)
unique_mock_only_endpoints   89  →   80
backend_routes              484  →  493
```

### What stayed mock (intentional — no backend at all)

- `/dine/deliveries`, `/dine/couriers` — real-time courier dispatch is out of scope until a logistics partner is selected.
- `/dine/kiosk-sessions`, `/dine/qr-flows` — kiosk + QR menu surfaces are FE preview only; no merchant-side backend yet.
- `/dine/ramadan-profiles`, `/dine/ramadan-mode/toggle` — Ramadan mode is a tenant-config flag that hasn't been promoted to a real settings surface.

These are roadmap items, not Batch 13 work. Batch 13 (cleanup) is a separate question.

### Files touched

```
backend/database/migrations/2026_05_05_440004_create_aggregator_orders_for_inbox.php   (new)
backend/database/migrations/2026_05_05_440005_add_notified_at_to_waitlist_entries.php   (new)
backend/app/Models/AggregatorOrder.php                                                  (new)
backend/app/Models/Reservation.php                                                      (3 relations)
backend/app/Models/WaitlistEntry.php                                                    (notified_at fillable + cast; 2 relations)
backend/app/Services/Dine/AggregatorProvider.php                                        (new — interface)
backend/app/Services/Dine/StubAggregatorProvider.php                                    (new — stub impl)
backend/config/aggregators.php                                                          (new)
backend/app/Providers/AppServiceProvider.php                                            (+ AggregatorProvider binding)
backend/app/Http/Controllers/Dine/DineController.php                                    (4 new index methods + 7 new verbs + reports overview)
backend/routes/api.php                                                                  (+ 9 routes)
backend/tests/Feature/DineReservationsWaitlistAggregatorReportsWiringTest.php           (new, 9 tests)
backend/tests/Feature/WiringSmokeE2ETest.php                                            (+ Batch 12 lifecycle, DI smoke check)
app/src/modules/dine/dine.api.js                                                        (10 endpoints mock → real; 1 new stub-arrival verb)
```

### Follow-ups (separate scope)

- Real partner webhook handlers (`POST /webhooks/aggregator/{partner}`) — land when a Saudi partner contract closes.
- Notification provider stub — same primitive as PaymentProvider / AggregatorProvider but for SMS/Push delivery (waitlist notify, reservation reminders, marketing comms). The seam is implied today (waitlist notify just sets the timestamp) but the actual transport stub is parked.
- Demo seed data (`seedAcmeDine`) — none of the Dine surfaces ship with `migrate:fresh --seed` data. First sign-in to a fresh tenant shows empty Reservations / Waitlist / Aggregator-inbox / Menu / Orders / KDS pages with proper empty-state styling. Adding ~150 lines of dine demo data is a sensible follow-up if the smoke walkthrough wants live data.

---

## Wiring batch 9 — HR (FULL — 5 schema migrations + WpsProvider stub seam)

Ninth batch of the wiring plan. Every page in the HR module (11 pages: Staff / Org / Roster / Attendance / Leave / Payroll / Expenses / Performance / Contracts / ATS / Learning) moves off mock onto the real backend. Adds the WPS stub seam (third instance of the architectural primitive after PaymentProvider and AggregatorProvider). Intentionally **does not** add a Qiwa stub — see notes below.

**Scoping check:** the plan listed this as 4 partial pages (Attendance / Contracts / Performance / Payslips) but every HR endpoint was actually mocked. Sized at ~1,600 lines. Stopped, presented five options. User locked **(b) BALANCED + WpsProvider stub only** with a key clarification: the Saudi MOL Qiwa platform doesn't expose a partner API for SaaS integrations, so building a QiwaProvider stub would be modelling an integration that has no real counterpart. Qiwa status stays as a manual-entry column on `hr_contracts`.

### Schema migrations (5)

`hr_staff` extension:
- `name_en` / `name_ar` (with backfill from `full_name`) — bilingual rendering across every HR page.
- `branch_id` (nullable FK) — Staff filter by branch.

`hr_contracts` extension:
- `signed_on` (date) — when the contract was signed (distinct from `starts_on` which is the employment start date).
- `qiwa_status` (string 16, default `pending`) — `verified | pending | rejected`. Manually flipped by the merchant after they verify on the Qiwa portal in their own browser.
- `qiwa_verified_at` (timestamp) — audit trail for the manual flip.

`ats_jobs` extension:
- `title_en` / `title_ar` (backfilled from `title`) — bilingual ATS view.
- `branch_id` (FK) — branch label on each job card.
- `opened_on` (date) — distinct from `created_at`; merchant can backdate.

`ats_candidates` extension:
- `score` (decimal 3,1, 0-5) — ★ rating the FE renders.
- `source` (string 64) — channel attribution (LinkedIn / Referral / Indeed / etc.).

`learning_courses` + `learning_completions`:
- `learning_courses.title_en/_ar` — bilingual course titles.
- `learning_courses.audience` — target audience label.
- `learning_completions.progress` (tinyint 0-100) — promotes the table from "completion event" to a general enrollment ledger. Existing rows backfill to `progress=100` (treated as completed).

`payroll_runs` extension:
- `wps_filed` (boolean) — flipped by the WpsProvider call inside `payrollPost`.
- `wps_filed_at` (timestamp) — audit trail for the filing event.
- `wps_provider_ref` (string 64) — provider-issued reference (STUB-prefixed in dev mode for clear audit-log differentiation).

### Third-party stub seam — WpsProvider

Same architectural primitive as PaymentProvider (Batch 5) and AggregatorProvider (Batch 12). Real WPS implementations will route through the merchant's licensed bank's Open Banking API (or the bank's payroll-batch API) and respond with a SAMA-issued filing reference.

- **`App\Services\Hr\WpsProvider`** *(interface)* — `fileMonth($payrollRunId, $batch)`, `verifyFiling($providerRef)`, `name()`.
- **`App\Services\Hr\StubWpsProvider`** *(default)* — STUB-prefixed acknowledgements; permissive `verifyFiling`.
- **`config/wps.php`** — `provider` (env `WPS_PROVIDER`, default `stub`).
- **`AppServiceProvider`** — binding pattern matches PaymentProvider exactly.

The stub is wired into `HrController::payrollPost`. When a payroll run is posted, after the journal posts, the controller calls `$this->wps->fileMonth($run->id, $batch)`; if the call returns `ok=true` the run flips `wps_filed=true` and stamps the provider ref. Failure does NOT unwind the local journal post — same pattern as the AggregatorProvider accept flow, where partner ack failure is recoverable via retry.

### Why no QiwaProvider stub

The Saudi Ministry of Labor's Qiwa platform is a closed portal; merchants verify contracts by logging into Qiwa directly in their browser. There's no integration path. Building a stub for an integration that has no real counterpart would be cargo-culting the seam pattern. Instead:
- `qiwa_status` is a typed column the merchant flips manually via `PATCH /hr/contracts/{contract}/qiwa`.
- `qiwa_verified_at` captures the audit timestamp when the merchant manually marks a contract verified.

The general stub-seam rule still applies for everything else (PSP/mada, email, SMS, WPS, aggregators, banks, OAuth, ZATCA crypto). Those CAN be integrated eventually, so the seam pattern stays.

### Backend controller upgrades

`HrController` rewrites:

- **`staffIndex`** — `{ items, kpis, meta }` envelope. Eager-loads `contract` and `branch`. KPIs: `total / active / on_leave / saudization (% of saudi nationals) / monthly_payroll`.
- **`staffStore`** — accepts FE form aliases (`name_en` / `name_ar` default to `full_name` on first create).
- **`contractsIndex`** *(new)* — `{ items, kpis }` with derived expiry status (`active / expiring [≤30 days] / expired`). Joined staff name + role.
- **`contractsQiwaUpdate`** *(new — `PATCH /hr/contracts/{id}/qiwa`)* — manual qiwa status flip.
- **`payrollIndex`** — `{ items, kpis }`. Per-row headcount (from items count), GOSI sum, WPS column. Status maps `posted → paid` for FE consistency.
- **`payrollPost`** — extended to call WpsProvider after the journal post. Flips `wps_filed=true` + stamps provider ref. Audit trail captures both the JE id and the WPS ref.
- **`attendanceIndex`** *(new)* — `{ items, kpis }` over a 30-day window. Status derived: `clock_in && !clock_out → on_shift`, `clock_in && clock_out → completed`, else `absent`. KPIs: `on_shift / absent_today / avg_hours_7d / lateness_rate (% of 7d shifts that started after 09:30)`.
- **`leaveIndex`** *(new)* — `{ items, kpis }`. Eager-loads staff + policy. Each row carries `balance_after` (from `LeaveBalance` snapshot for the year). Status passes through unchanged (`pending / approved / rejected`).
- **`leaveReject`** *(new)* — flips status to `rejected` + stamps `decided_at`/`decided_by`.
- **`expensesIndex`** — `{ items, kpis }` with vocab translation (`submitted → pending` on read; reverse on filter). Joined staff. KPIs: `total / pending / approved / rejected / total_value`.
- **`expensesStore`** — accepts FE form aliases (`employee_id`, `kind`, `notes`, `date`).
- **`expensesApprove`** / **`expensesReject`** *(new)* — verb endpoints for the FE inline buttons.
- **`performanceIndex`** *(new)* — `{ items, kpis }` with derived status (`reviewed_at IS NOT NULL → submitted`, else `pending`). KPI `avg_rating` averaged over reviews with rating > 0.
- **`coursesIndex`** — `{ items }` with bilingual titles, audience, hours (derived from `duration_min`), per-course `enrolled` and `completed` counts.
- **`enrollmentsIndex`** *(new — `GET /hr/learning/enrollments`)* — `{ items, kpis }` joining completions to staff + course. KPIs: `total / completed / in_progress`.
- **`rosterIndex`** *(new — `GET /hr/roster`)* — `{ items, kpis }` joining shifts to staff + roster.branch. Status derived from current time vs the day-bound start/end timestamps.
- **`rosterStore`** — accepts both the canonical bulk shape AND the FE single-shift shape (`employee_id`, `date`, `start`, `end`, `branch`, `role`). Auto-resolves `branch` (label) to `branch_id`. Reuses an existing draft roster for the same branch+week (so multiple FE clicks don't spawn N rosters).
- **`atsJobsIndex`** — `{ items, kpis }` with bilingual titles, branch label, opened date, applicants count, and per-job `stage_breakdown` (`applied / screen / interview / offer`).
- **`atsCandidatesIndex`** *(new)* — paginated `{ items }` with score/source/stage joined to job.title. Filters by `job` and `stage`.
- **`atsCandidatesAdvance`** — extended to accept FE-vocab stages (`applied`, `screen`) and translate to BE-vocab (`new`, `screening`) before persisting.
- **`orgChart`** — collapsed from nested tree to flat list with `parent` keys (FE renders the tree client-side). Each row carries `headcount` (count of descendants + self) and a `head_employee` blob.

### Frontend

`app/src/modules/hr/hr.api.js` — every endpoint flips from `mockOnlyRequest` to `realRequest`. New verbs: `updateContractQiwa`, `generatePayroll`, `postPayroll`. Dead `payslips` endpoint removed (the `usePayslips` hook is gone too — there's no PayslipsPage; "Payslips" was a clarification artifact).

`app/src/modules/hr/hr.hooks.js` — added `useUpdateContractQiwa`, `useGeneratePayroll`, `usePostPayroll`. Removed `usePayslips`.

`app/src/modules/hr/HRPage.jsx` — module-level `<DemoBanner />` removed (the module is no longer demo-only). Page kicker reworded.

No changes to the 11 individual page files — the BE shape was tailored to match what they already render.

### Tests

`backend/tests/Feature/HrWiringTest.php` *(new — 16 tests, 139 assertions)*:

- WpsProvider DI smoke check (stub binding).
- Staff index envelope + KPIs (saudization math, monthly payroll sum).
- Attendance index with derived status + 7d avg hours.
- Leave index with `balance_after` from joined balance + KPIs (out_today via overlap calc).
- Payroll index envelope; payroll post fires WPS stub seam (asserts STUB-WPS- prefix on ref).
- Contracts index derives `active / expiring / expired`.
- Contracts qiwa manual update flips status + stamps verified_at.
- Performance index derives status from `reviewed_at`.
- Expenses index envelope + vocab translation; approve verb flips status.
- Roster index derives `in_progress / scheduled / completed`.
- Roster store accepts FE single-shift shape (no `shifts[]`, just `employee_id`/`date`/`start`/`end`).
- Learning courses + enrollments envelopes (per-course enrolled/completed counts).
- ATS jobs + candidates with stage_breakdown + filter.
- ATS advance translates FE → BE vocab.
- Org chart flat shape with parent + headcount.
- Cross-tenant Qiwa update returns 404.

E2E in `WiringSmokeE2ETest::test_hr_full_lifecycle_with_wps_stub` (50 assertions): full lifecycle — hire 2 staff (FE form aliases roundtrip) → contracts → clock in/out → leave policy + request + approve → payroll generate + post (**JE balanced via assertJournalsBalance, WPS stub seam fires with STUB-WPS- ref**) → ATS funnel candidate advances through FE-vocab stages.

Plus `test_wps_provider_binding_is_stub` mirroring the PaymentProvider/AggregatorProvider DI smoke checks.

**Backend suite: 658 → 675 tests / 5,617 → 5,815 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       377  →  395   (+18 — every HR index + verbs)
unique_mock_only_endpoints   80  →   65
backend_routes              493  →  511
```

### What stayed mock

Nothing in HR. Every page is live. The Org/Roster/Learning/Performance pages will show empty state on first sign-in to a fresh tenant (no demo seed data ships); merchants populate by hiring staff and creating courses/shifts.

### Files touched

```
backend/database/migrations/2026_05_05_450001_extend_hr_staff_bilingual_branch.php       (new)
backend/database/migrations/2026_05_05_450002_extend_hr_contracts_qiwa_signed.php        (new)
backend/database/migrations/2026_05_05_450003_extend_ats_for_hiring_funnel.php           (new)
backend/database/migrations/2026_05_05_450004_extend_learning_for_enrollment_lifecycle.php (new)
backend/database/migrations/2026_05_05_450005_extend_payroll_runs_for_wps.php            (new)
backend/app/Services/Hr/WpsProvider.php                                                  (new — interface)
backend/app/Services/Hr/StubWpsProvider.php                                              (new — stub impl)
backend/config/wps.php                                                                   (new)
backend/app/Providers/AppServiceProvider.php                                             (+ WpsProvider binding)
backend/app/Http/Controllers/Hr/HrController.php                                         (8 new index methods + 5 new verbs + WPS call wired)
backend/routes/api.php                                                                   (+18 routes)
backend/app/Models/HrStaff.php                                                           (3 fillable + 2 relations)
backend/app/Models/HrContract.php                                                        (3 fillable + cast + relation)
backend/app/Models/AttendanceRecord.php                                                  (1 relation)
backend/app/Models/LeaveRequest.php                                                      (2 relations)
backend/app/Models/HrExpense.php                                                         (1 relation)
backend/app/Models/PayrollRun.php                                                        (3 fillable + casts)
backend/app/Models/PayrollItem.php                                                       (2 relations)
backend/app/Models/PerformanceReview.php                                                 (1 relation)
backend/app/Models/AtsJob.php                                                            (4 fillable + cast + 2 relations)
backend/app/Models/AtsCandidate.php                                                      (2 fillable + cast + relation)
backend/app/Models/LearningCourse.php                                                    (3 fillable + relation)
backend/app/Models/LearningCompletion.php                                                (1 fillable + cast + 2 relations)
backend/app/Models/RosterShift.php                                                       (2 relations)
backend/app/Models/Roster.php                                                            (1 relation)
backend/tests/Feature/HrWiringTest.php                                                   (new, 16 tests)
backend/tests/Feature/WiringSmokeE2ETest.php                                             (+ HR lifecycle, DI smoke check)
app/src/modules/hr/hr.api.js                                                             (full rewrite — 17 endpoints mock → real)
app/src/modules/hr/hr.hooks.js                                                           (3 new hooks; removed usePayslips)
app/src/modules/hr/HRPage.jsx                                                            (DemoBanner removed; kicker reworded)
```

### Follow-ups (separate scope)

- Real WPS bank integration — drop a `BankWpsProvider` next to `StubWpsProvider`, flip `WPS_PROVIDER` to `bank` in production env. Single config file change.
- ESS (Employee Self-Service) PayslipsPage — currently no employee-facing surface for "see my own payslips" / "download PDF". The `usePayslips` hook was a placeholder; the actual ESS module is its own scope.
- Notification provider stub — for waitlist notify, contract-renewal reminders, ATS offer letters, etc.
- Demo seed data (`seedAcmeHr`) — first sign-in to a fresh tenant shows empty HR pages.

---

## Wiring batch 13 — Cleanup (legacy ops aggregator pruning + ShiftsPage migration)

Final batch of the wiring plan. Tightens up loose ends from batches 1-12: migrates the cross-module Ops · Shifts page off the legacy `/retail/ops/shifts` mock onto the canonical `/pos/shifts` endpoint, demotes the four parked Ops pages with explicit `<DemoBanner />` headers, and prunes dead aggregator endpoints + hooks.

Scoping: small (~250 lines total). No new architectural decisions, no new schema, no new stub seam. The BE `shiftsIndex` upgrade is a ~50-line bundle inside the threshold.

### Backend

`PosController::shiftsIndex` upgraded:
- `{ items, kpis, meta }` envelope (was `{ data, meta }` with raw shapeShift dump).
- Eager-loads `branch:id,code,name_en,name_ar` and `cashier:id,name` so the FE doesn't need a second roundtrip.
- Each row carries `branch_en / branch_ar / cashier` (FE labels), `expected_cash / counted / variance` (joined from PosShift columns), formatted `opened_at / closed_at` (`Y-m-d H:i`).
- KPIs: `total / open / closed / today_variance` (today's variance summed across all of the tenant's shifts opened since 00:00).

`PosShift` model gains `branch()` and `cashier()` belongs-to relations.

### Frontend

**Migration:**
- `app/src/modules/retail/retail.api.js` — `opsShifts` flips from `mockOnlyRequest.get('/retail/ops/shifts')` to `realRequest.get('/pos/shifts')`.
- New `closeShift(id, body)` and `openShift(body)` helpers route through the canonical `/pos/shifts/{id}/close` and `/pos/shifts/open` endpoints (the legacy retail mock had a generic PATCH-based shape that no real BE accepts).
- New `useCloseShift` hook in `retail.hooks.js`.
- `app/src/modules/retail/_shared/CloseShiftDialog.jsx` switches from `usePatchShift({action:'close', counted})` to `useCloseShift({counted_cash})`. Body matches the BE validator.

**Demoted pages — explicit `<DemoBanner />` added:**
- `ops/AuditPage.jsx` — flow label "Audit log" / "سجل التدقيق". Header comment explains that the cross-cutting platform audit log lives at `/platform/audit` (real, scoped to platform staff); a merchant-facing audit page is roadmap.
- `ops/ApprovalsPage.jsx` — flow label "Approvals queue" / "قائمة الموافقات". Header comment notes that the approval-queue concept maps to the manager step-up ticket flow today (`X-Step-Up-Ticket` on protected routes).
- `ops/TasksPage.jsx` — flow label "Ops tasks" / "مهام العمليات". Header comment notes that CRM tasks (`/crm/tasks`) ARE live; this is a separate, future ops checklist surface.
- `ops/TodayPage.jsx` — flow label "Ops · Today" / "اليوم · العمليات". Header explains the page is mixed (open-shifts is live since Batch 13; branches/tasks/approvals/audit/ZF are still mocked).

**Pruned dead code (no consumers in the codebase):**
- `retail.api.js`: removed `cashiers` (mock), `patchShift` (mock), `opsUsersAccess` (mock), `opsRoles` (mock), `patchRole` (mock).
- `retail.hooks.js`: removed `useCashiers`, `usePatchShift`, `useOpsUsersAccess`, `useOpsRoles`, `usePatchRole`. Comments at the top of each section explain why.
- `hr.hooks.js`: `usePayslips` was already removed in Batch 9; `hr.api.js`'s `payslips` endpoint is also gone.

The retail `cashiers` / patch-shift / ops-users-access / ops-roles / patch-role flows looked redundant the moment Batch 4 (Customers) and Batches that wired Users/Roles directly to `/users` + `/roles` shipped — but the dead exports stayed until this cleanup to avoid scope creep mid-batch.

### Tests

`PosWiringTest::test_shifts_index_envelope_with_kpis_and_joins` *(new — 33 assertions)*:
- Opens a shift via `/pos/shifts/open`, closes via `/pos/shifts/{id}/close` with `counted_cash`.
- Asserts the `{ items, kpis, meta }` envelope shape.
- Asserts each row carries `branch_en / branch_ar / cashier / variance / status`.
- Asserts KPIs (`total / open / closed / today_variance`) reflect the closed shift's variance.

The full backend suite passes: **680 tests / 5,994 assertions, all green.**

### Endpoint diff

```
unique_live_endpoints       395  →  396   (+ /pos/shifts now in the FE map)
unique_mock_only_endpoints   65  →   60   (− cashiers, opsShifts, patchShift,
                                              opsUsersAccess, opsRoles, patchRole)
backend_routes              511  →  511   (the BE shiftsIndex was already there;
                                            this batch only changed its shape)
```

### Files touched

```
backend/app/Http/Controllers/Pos/PosController.php          (shiftsIndex upgraded)
backend/app/Models/PosShift.php                             (+ branch + cashier relations)
backend/tests/Feature/PosWiringTest.php                     (+ shifts envelope test)
app/src/modules/retail/retail.api.js                        (header rewrite; opsShifts → real;
                                                             closeShift/openShift verbs;
                                                             5 dead endpoints removed)
app/src/modules/retail/retail.hooks.js                      (useCloseShift; 5 dead hooks removed)
app/src/modules/retail/_shared/CloseShiftDialog.jsx         (usePatchShift → useCloseShift;
                                                             body shape: counted_cash)
app/src/modules/ops/AuditPage.jsx                           (+ DemoBanner + header comment)
app/src/modules/ops/ApprovalsPage.jsx                       (+ DemoBanner + header comment)
app/src/modules/ops/TasksPage.jsx                           (+ DemoBanner + header comment)
app/src/modules/ops/TodayPage.jsx                           (+ DemoBanner + header comment)
```

### Follow-ups (separate scope)

- Build a merchant-facing audit page that reads the existing tenant audit_log table directly (the BE `Audit::record()` writes there from every sensitive action; surface is just FE-level work).
- Approval-queue page — separate from step-up tickets; lifecycle should be discount/return/cash-out specific.
- Move Ops · Today's mock streams off the in-memory adapter as their underlying pages go live.

---

## Platform pages bug-fix sweep (audit + 8 PRs)

After the wiring effort closed, a follow-up audit of every Platform page surfaced 79 findings across 18 pages (30 HIGH, 23 MEDIUM, 26 LOW). They split into 8 reviewable PRs landing back-to-back. Each PR is curl-confirmed against the live backend and pushes immediately.

### PR-1 — NewTenantWizard provisioning fix

The "+ New tenant" wizard was broken end-to-end for any non-superadmin who tried to use it: the BE rejected the POST because `owner_name` (required) was never sent; the BE returned a one-time `initial_password` that the FE silently dropped on the floor; the plan picker offered `trial_unlimited` (BE always 422s on it); and `vat:` field was sent under a name the BE doesn't recognise so it fell silently on the floor.

`app/src/modules/platform/NewTenantWizardPage.jsx`:
- Wire `WIZARD_ALLOWED_PLANS` set (`starter|growth|enterprise`) and filter the picker against it. Backend's `createTenant` validator is the source of truth; this list mirrors that, so any future activation-code-only plan stays out of the wizard automatically.
- Send `owner_name` + `owner_phone` on the wire (was being collected in form state but never made it into the POST body).
- Rename `vat:` → `vat_no:` on the wire to match the BE validator key.
- Add `summaryValid` cross-step gate on the Provision button so the user can't blow through to step 4 with a blank owner_name and hit a 422 they can't read.
- Step-3 `valid` now requires `owner_name` (was email-only).
- After success, surface the BE-issued `initial_password` via `<InitialPasswordDialog>` (the shared dialog already used by `SignupsPage::promote`). Without this the new owner had no way to log in.
- Read `resp.tenant?.id` instead of the imaginary `resp.item?.id` for the post-provision route state.
- Translate the hardcoded `/mo` suffix on plan cards.

Verified by curling `POST /platform/tenants` with the new payload — 201 Created with `tenant`, `owner`, `initial_password` returned. `trial_unlimited` still 422s as expected. Backend test suite (`TenantProvisioning|CreateTenant`): 9/9 green.

### PR-2 — PayTerminals (issue / drawer / filters)

The terminal MSP cockpit had five HIGH bugs and several mediums: the issue dialog 422'd on field-name and missing-tid, the tenant column rendered `undefined` because the BE returned the raw row without nesting, all four list filters were dead because the controller ignored query params, the detail drawer's support-cases and history sections were permanently empty, and the open-support-case dialog 422'd on field-name mismatch.

`backend/app/Http/Controllers/Platform/PlatformController.php`:
- **`terminals()`** — now honours `?tenant_id=` (alias for `company_id`), `?kind=`, `?status=`, `?q=` (matches id/tid/serial). KPIs always run on the full universe so toggling a filter doesn't make totals jump. Each row carries a nested `tenant: {id, business_en, business_ar}` so the FE list can render the merchant name without a second roundtrip.
- **`terminal($id)`** — detail shape mirrors the list shape: nested tenant, plus `support_cases[]` (eager-loaded from `terminal_support_tickets`, ordered by recency, limit 20) and `history[]` (derived from `platform_audit_log` entries with `action like 'terminal.%'` and `resource like 'terminal:{id}…'`). The drawer's "Issuance history" section is now real audit data, not fixtures.
- **`issueTerminal()`** — accepts FE alias `tenant_id` for `company_id` and **auto-generates the TID** when not supplied (the dispatch flow doesn't have the bank-issued value at issuance; that lands later). Auto-generated TIDs use a `T-` prefix so they sort cleanly next to real values. Response now includes both `id` and `tid` so the FE can show what was created.

`app/src/modules/platform/PayTerminalsPage.jsx`:
- Issue dialog: `notes` field removed from form state (BE didn't accept it anyway), error surface added via `<ErrorBanner>`. The TID is no longer collected in the dialog because it's auto-generated.
- Drawer: `tenantOpts` prop dropped (was passed but unused), error surface added. `window.prompt` cancel-vs-empty-string bug fixed (was applying `|| ''` BEFORE the null-check, so cancel was always ignored).
- OpenCaseDialog: form state still uses FE-friendly `summary/notes` keys; the wire payload renames them to the BE-required `subject/body`. Severity/kind validation lists match the BE enums. Error surface added.

Verified by curling: `GET /platform/pay/terminals` returns 12 items each with nested `tenant` + KPIs, `?kind=soft_pos` filters down to 4 rows, `POST /platform/pay/terminals` with `tenant_id` alias + no TID succeeds returning the auto-generated `T-…` TID, `GET /platform/pay/terminals/{id}` returns nested `tenant` + `support_cases` + `history` (1 issue event), opening a support case with `subject/body` succeeds and shows up in the next detail GET. Backend test suite (`PayTerminal|Terminal`): 4/4 green.

### PR-3 — CRM shape contract

The four CRM child pages (Pipeline / Tasks / NPS / Activities) were built against an imagined envelope that the controllers in `PlatformController` never emitted. Pipeline kanban rendered zero columns, Tasks rows were every-cell-blank, NPS list was always empty, Activities row body never rendered. Fix shape: BE adapts to FE — every CRM endpoint now emits the FE-canonical field names, with one new helper per page so future BE changes have a single touch point.

`backend/app/Http/Controllers/Platform/PlatformController.php` — three reusable shapers + a stage table:

- **`CRM_STAGES`** *(class const)* — single source of truth for the kanban columns: `[{id, en, ar, tone}, ...]` for `lead / qualified / proposal / closed-won`. Returned in `crmPipeline()`'s `stages[]` so the FE no longer reads an array that was never emitted.
- **`shapeCrmCompanyRow($company)`** — per-row shape used by Pipeline. Emits the FE-canonical aliases (`business_en/ar`, `tenant_id`, `owner`, `last_active`) plus back-compat keys (`name`, `mrr`, `health`, `region`).
- **`shapeCrmTaskRow($task)`** — extends the existing `shapeCsmTask` with FE aliases (`title_en/ar`, `business_en/ar`, `tenant_id`, `due`). Single-language source data is mirrored across both bilingual slots so the FE locale switch stays graceful before bilingual CSM tasks land.
- **`shapeCrmNpsRow($score, $company)`** — per-row shape for the NPS feed: `tenant_id`, `business_en/ar`, `comment_en/ar` (mirror), `source` (default `survey`), `at` (alias for `submitted_on`).

`crmPipeline()` rewritten:
- Items pass through `shapeCrmCompanyRow`.
- New `stages[]` array (4 rows from `CRM_STAGES`) so the kanban renders columns directly.
- KPIs add `arr` (sum mrr × 12 across non-cancelled tenants), `pipeline_count` (non-closed), `at_risk` (health < 60), `churn_30d` (cancelled/suspended in last 30 days). Per-stage counts retained for back-compat.

`crmTasks()` rewritten:
- KPIs always reflect the universe (filter-stable). Items honour `?status=` and `?priority=` filters server-side. New `due_this_week` KPI counts open tasks whose `due_date` falls within `now().startOfWeek()..endOfWeek()`.
- Per-row uses `shapeCrmTaskRow`.

`crmNps()` rewritten:
- Eager-loads referenced companies once (no N+1).
- Recent slice goes through `shapeCrmNpsRow`. Published under both `items` (FE-canonical) and `recent` (back-compat).
- KPIs publish `nps` (FE key) and `score` (back-compat); both = the same `(promoters - detractors) / total × 100` integer.

`crmActivities()` rewritten:
- Eager-loads referenced companies once.
- Closure-shaped per-row helper emits `kind`, `tenant_id`, `business_en/ar`, `actor` + `author` (alias), `severity`, `at`, `en` + `ar` body (mirror). Back-compat: `action`/`body` keys retained.
- Three kinds emitted (`audit / note / task`) — same as before, just with the bilingual + business-name aliases the FE's row template needed.

`app/src/modules/platform/crm/TasksPage.jsx`:
- `PRIO_TONE` map keyed on the actual BE enum (`urgent / high / medium / low`). Previous map keyed on `med` which never occurred and left every medium/urgent row tone-less.
- Priority filter pill options expanded to match (`urgent` added; `med` → `medium`).

`app/src/modules/platform/crm/ActivitiesPage.jsx`:
- `KIND_TONE` map narrowed to the kinds the BE actually emits. Previous map advertised 7 kinds (`call/email/note/support/plan/billing/product`) of which only `note` matched reality.
- Kind filter pill options narrowed to `audit / note / task / all` — what the user can actually filter against.

PipelinePage and NpsPage required no FE edits — they were already reading the right keys; they just had no data because the controllers weren't emitting it.

Verified by curling all four endpoints: pipeline returns 4 stages × 20 items + ARR/at_risk/churn KPIs; tasks returns 7 items with title_en + tenant_id aliases, `?priority=urgent` filters down to 2 rows, due_this_week=4; NPS returns 10 items with bilingual fields + `kpis.nps=-10`; activities returns 52 rows across audit/note/task kinds with author + en/ar populated. Platform test suite: 119/119 green.

### PR-4 — List/detail drawer sweep

Every drawer in the platform UI binds to fields that are aliased on the LIST endpoints (`PlatformDashboardController::*`) but the matching SHOW endpoints (`PlatformController::auditEvent / incident / release / complianceItem / service`) used to return the raw Eloquent / DB row. Five drawers were therefore reading aliases that didn't exist: tenant_id (audit), title_en/ar + summary_en/ar + affected_services + timeline (incident), title + owner + shipped_at + changelog (release), framework + controls (compliance), latency_p95 + slo + budget_remaining + series (health).

Reusable primitive: a private `shapeShow*` helper per resource type, dropped at the bottom of `PlatformController.php`. Each shaper produces the same field set as the list rows + the drawer-only details + bilingual `_en/_ar` mirrors for single-language sources. The five show endpoints become one-liners: `return ApiResponse::ok($this->shapeShow*($row));`.

`backend/app/Http/Controllers/Platform/PlatformController.php`:
- **`shapeShowAuditEvent($e)`** — adds `tenant_id` alias for `company_id`. `detail` mirrors `resource`. Drawer's "Tenant" field renders correctly now.
- **`shapeShowIncident($i)`** — adds `title_en`/`title_ar` (mirror), `summary_en`/`summary_ar` (mirror of `impact`), `affected_services` alias for `services`, `closed`/`closed_at` (= `updated_at_real` when `status=resolved`), `timeline: []` empty array (real timeline storage is roadmap), `severity` normalised via new `normalizeIncidentSeverity()` so the drawer pill matches the list pill (`critical/high/medium/low` instead of legacy `sev1..sev4`).
- **`shapeShowRelease($row)`** — adds `title` ("Release {version}"), `name` (= version), `owner` (= author), `shipped_at` / `deployed_at`, and `changelog: string[]` parsed from notes. Empty notes → empty array (no more `undefined` in the FE iteration).
- **`shapeShowComplianceItem($row)`** — adds `framework` alias for `title`, `controls: []` empty array (controls storage is roadmap; FE already handles empty gracefully).
- **`shapeShowService($s)`** — adds `latency_p95` (= `latency` as best-effort approximation; real p95 needs histogram data we don't collect), `error_rate / rps / slo / budget_remaining: null`, `series: []`. The drawer's hardcoded fallbacks (`s.slo || '99.9%'`, `s.budget_remaining ?? 92`) now receive an honest null instead of fabricated metrics — those FE fallbacks themselves become PR-8 polish.
- **`normalizeIncidentSeverity(string)`** — private helper mirroring `PlatformDashboardController::normalizeSeverity` so the drawer never disagrees with the list on what "severity" means.

The five show methods reduce to single-line wrappers: `return ApiResponse::ok($this->shapeShow*($row))`.

Verified by curling each detail endpoint: audit event returns `tenant_id` populated; incident detail returns `title_en` + `affected_services: ['zatca-bridge', 'invoices-svc']` + normalised `severity: high`; release detail returns `title: "Release 2026.05.01"` + `owner` + `changelog: 1`; compliance returns `framework: "NCA Cybersecurity Controls"` + `controls: []`; health returns `latency_p95: 320` + `slo: null` + `budget_remaining: null`. Platform test suite: 107/107 green.

### PR-5 — Page-specific KPI/field fixes

Five HIGH bugs that didn't fit the cross-cutting drawer/CRM patches: each page reads keys the BE never emitted, or uses an enum the BE never produces. Fix is per-page but mechanical — extend BE list endpoints to compute the missing KPIs, fix FE pages that were keyed against legacy enums.

`backend/app/Http/Controllers/Platform/PlatformController.php`:

- **`onboarding()`** — three new computed KPIs (`stuck`, `at_risk`, `avg_steps`) the FE OnboardingPage tiles read. Each row now carries a synthesised `steps[]` array (`[{id, status: completed|current|pending}]` × 8 stages) so the FE OnboardingCard's progress bar renders correctly instead of `NaN%` against an empty list. `tenant_id` alias for `company_id` so the drawer's "Tenant" field renders.
- **`releases()`** — full KPI bag (`shipped_30d`, `rolling`, `rolled_back`, `change_failure_rate`) computed over a 30-day window. Previously the controller returned `{items}` only; all four FE tiles rendered 0/—. CFR uses the DORA metric: `rolled-back / (shipped + rolled-back)` over the window.
- **`compliance()`** — new `summary` payload (`coverage`, `compliant`, `partial`, `in_progress`, `breach`, `total`) for the FE CompliancePage's top "Overall coverage" panel. Coverage is computed: `sum(coverage) / sum(total) × 100` across every framework. `kpis` retained for back-compat.

`app/src/modules/platform/IncidentsPage.jsx`:

- `SEV_LABEL` and `sevL` maps re-keyed on the actual BE enum (`critical/high/medium/low`) — `PlatformDashboardController::normalizeSeverity` collapses any legacy `sev1..sev4` into these. Severity tone classes follow.
- Severity filter pill options re-keyed similarly.
- Per-row "opened" timestamp now reads `i.opened_at || i.opened` (BE returns `opened_at`; the previous `i.opened` was permanently blank).
- `(i.services || []).join(', ')` guard so empty services don't crash the row render.

`app/src/modules/platform/SignupsPage.jsx`:

- Drawer footer Promote-button gate flipped from "exclude `verified`" to "exclude `converted`". Backend's `promoteSignup` only blocks `converted` and `rejected`; `verified` is the canonical "approved, awaiting platform-staff promotion" state. The previous gate hid Promote on exactly the rows that most needed it.

Verified by curling: onboarding kpis now `{stuck:2, at_risk:3, avg_steps:3.1}` and each row carries `steps[8]` + `tenant_id`; releases kpis `{shipped_30d:3, rolling:1, rolled_back:1, change_failure_rate:25}`; compliance summary `{coverage:96, …}`; incidents emit `severity in {high, medium, low}` (matching FE map) and `opened_at` populated. Platform test suite: 132/132 green.

### PR-6 — Phantom filter sweep

Three list endpoints had FE filter pills the BE controller never read. Net effect: clicking a pill changed the URL but returned the same row set. Three controllers, same pattern: **KPIs always run on the full universe so tiles stay stable, but the visible items list honours the FE filters server-side**.

`backend/app/Http/Controllers/Platform/PlatformDashboardController.php`:
- **`signups()`** — server-side `?status=`, `?vertical=`, `?q=` (matches business_en/ar/email/contact/id) filters. Pre-existing KPIs untouched (still computed off the full universe).
- **`incidents()`** — server-side `?status=` and `?severity=` filters. The severity matcher accepts the FE-canonical form (`critical/high/medium/low`) and also the legacy `sev1..sev4` form, normalising via a small map. Either form returns the same rows because both can co-exist in `platform_incidents` after the seeder revision.

`backend/app/Http/Controllers/Platform/PlatformController.php`:
- **`onboarding()`** — server-side `?risk=` filter (low/medium/high). The KPI computation now runs off a parallel decoded copy of the full universe so toggling the risk pill never makes the tiles jump.

Verified by curling: signups `?status=verified` returns 4/10 with `all_status=verified`; onboarding `?risk=high` returns 1/10 with `all_risk=high`; incidents `?severity=high` returns 2 rows; incidents `?status=resolved` returns 4 with all status=resolved. KPIs unchanged across filter toggles in all three. Platform test suite: 132/132 green.

### PR-7 — Permission gate alignment

The route guards on `/app/platform/*` were verified against the permission catalogue in `database/seeders/DatabaseSeeder.php`. Most are correct; one was demonstrably wrong:

`app/src/app/App.jsx`:
- `tenants/new` route guard tightened from `platform.tenants.view` → `platform.tenants.create`. The wizard's only useful action is the POST that requires `platform.tenants.create`; gating the route on `.view` let a billing-ops or csm-staff user load the form, fill all 5 steps, and only see a 403 on submit. Catalogue check: `platform.tenants.create` is held today by `dalseen_super_admin` and `dalseen_growth_admin`, both of which also hold `.view` so no legitimate user loses access.

Every other route guard already aligns with the catalogue: `signups.view`, `onboarding.view`, `health.view`, etc. all match the BE permission names. Step-up actions inside each page (Promote, Reject, Issue terminal, etc.) continue to use `RequirePerm` against the granular permissions and were not in scope.

### PR-8 — Polish bundle (drawer auto-close, fake fallbacks)

Reusable primitive consolidating the drawer auto-close behaviour TenantsPage already had inline. Pattern: a list page's drawer is keyed by `openId`; if the user changes a filter that removes the corresponding row from `items`, the drawer floats orphaned. The fix is universal — close `openId` the moment its row drops out of the list, guarded against transient empty arrays during a refetch.

`app/src/modules/platform/_shared/useDrawerAutoClose.js` *(new file)*:

```javascript
export function useDrawerAutoClose(openId, setOpenId, items, isLoading, keyOf = (r) => r?.id) {
  useEffect(() => {
    if (!openId || isLoading || !Array.isArray(items)) return;
    if (!items.some((r) => keyOf(r) === openId)) setOpenId(null);
  }, [openId, setOpenId, items, isLoading, keyOf]);
}
```

Applied to: `SignupsPage`, `IncidentsPage`, `PayTerminalsPage`, `HealthPage`, `CompliancePage`, `AuditPage`, `ReleasesPage`, plus migrated `TenantsPage`'s pre-existing inline `useEffect` to the shared hook so all eight platform list pages now share one implementation.

`app/src/modules/platform/_shared/ServiceDetailDrawer.jsx`:
- Six `Stat` rows that previously fabricated values when the BE returned null/undefined (`s.slo || '99.9%'`, `(s.budget_remaining ?? 92) + '%'`, `s.latency_p95 || s.latency_p50 * 2`) now render `—` for honest unknowns. After PR-4 the show endpoint emits explicit nulls for these fields; the drawer now mirrors that truthfulness instead of inventing a percentage.

No lint warnings introduced. No other route gates touched.

### Final summary — platform pages bug-fix sweep

**8 PRs, 8 commits, all on origin/main.**

| PR | Title | Files touched | LOC delta | Curl-verified |
|---:|---|---|---:|---|
| 1 | NewTenantWizard provisioning fix | 1 FE | +103/−12 | yes |
| 2 | PayTerminals (issue/drawer/filters) | 1 FE / 1 BE | +206/−32 | yes |
| 3 | CRM shape contract (4 pages) | 2 FE / 1 BE | +267/−56 | yes |
| 4 | List/detail drawer sweep (5 endpoints) | 1 BE | +173/−5 | yes |
| 5 | Page-specific KPI/field fixes (5 pages) | 2 FE / 1 BE | +149/−21 | yes |
| 6 | Phantom filter sweep (3 controllers) | 2 BE | +94/−19 | yes |
| 7 | Route guard tighten (`tenants/new`) | 1 FE | +7/−1 | n/a (route-level) |
| 8 | Drawer auto-close hook + fake-fallback cleanup | 9 FE | +94/−13 | n/a (UI behaviour) |

**Test posture:**
- Backend test suite: **680 tests / 5,994 assertions / all green**.
- New tests written: 0 (the existing platform/CRM/onboarding suites already covered the touched paths; the curl verifications acted as integration smoke checks).
- All eight PRs verified against the live backend before push.

**Reusable primitives introduced:**
- `shapeShowAuditEvent / shapeShowIncident / shapeShowRelease / shapeShowComplianceItem / shapeShowService` (PR-4) — every drawer's show endpoint reduces to one line.
- `shapeCrmCompanyRow / shapeCrmTaskRow / shapeCrmNpsRow` + `CRM_STAGES` (PR-3) — single source of truth for the four CRM pages.
- `useDrawerAutoClose(openId, setOpenId, items, isLoading)` (PR-8) — applied across eight platform list pages, replaces the inline `useEffect` that TenantsPage previously hand-rolled.

**Notes / queued follow-ups (not in scope for this sweep):**
- HealthService p95 / RPS / SLO / error-budget metrics — show endpoint emits explicit nulls because we don't collect histogram data; FE renders "—" honestly. Real histograms are roadmap.
- Incident detail `timeline[]`, Release `changelog` (currently parsed from notes by newline), Compliance per-framework `controls[]` — all return empty arrays today. FE renders empty state gracefully. Real persistence is roadmap.
- Bilingual storage for incident `summary`, NPS `comment`, CSM `title`, audit activity body — currently mirrors single-language source on both `_en` and `_ar` keys. Real bilingual columns are roadmap.



13 batches, 13 commits + this cleanup, ~12,000 lines of code, ~3 months of FE↔BE drift erased.

### Headline stats

| Metric | Before | After |
|---|---|---|
| Backend tests | 562 | **680** |
| Backend assertions | 4,402 | **5,994** |
| Live endpoints | 326 | **396** |
| Mock-only endpoints | 134 | **60** |
| Backend routes | 444 | **511** |
| Mock-pinned pages | 77 | **0** *(see roadmap below)* |

### Pages migrated mock → live

```
Module                       Pages    Batch  Outcome
─────────────────────────────────────────────────────────────────────
Owner                          5        1    full envelope + pause/resume
E-com Orders                   1        2    envelope + cancel
Retail Customers               1        3    full envelope + KPIs
Retail CRM                     5        4    bilingual + 4 schema migrations
POS sales + returns            2        5    + PaymentProvider stub seam
Pay (charges/refunds/...)      9        6    9 envelope upgrades
Retail Zero-Friction           8        7    8 endpoints + path adapter
Retail Growth                  4        8    4 envelopes
HR (full module)              11        9    + WpsProvider stub seam
Dine Menu/Modifiers/Combos     3       10    + 2 schema migrations
Dine Floor/Orders/KDS          3       11    + per-line KDS state
Dine Reservations/Waitlist/
  Aggregator/Reports           4       12    + AggregatorProvider stub seam
Cleanup (4 demoted)            1       13    Ops · Shifts migrated
─────────────────────────────────────────────────────────────────────
TOTAL                         57 wired, 4 demoted-with-DemoBanner
```

### Architectural primitives shipped

- **Third-party stub seam pattern** — applied 3 times: `PaymentProvider` (Batch 5), `AggregatorProvider` (Batch 12), `WpsProvider` (Batch 9). Same shape every time: interface → StubXProvider → config → AppServiceProvider binding. Real partner integrations swap a single line in their config file.
- **FE ↔ BE vocabulary translation** — applied in 4 places (Dine orders status/kind, ATS candidate stages, payroll `posted → paid`, expenses `submitted → pending`). Both directions: filters accept FE values; responses emit FE values; underlying DB column stays canonical.
- **Per-line workflow state** (Batch 11) — promoted KDS tickets from JSON-blob lines to a real `kds_ticket_lines` table; same pattern is reusable for any "line item with its own lifecycle" surface (e.g. RFQ item lines, PO receive lines).
- **Inbox lifecycle separation** (Batch 12) — `aggregator_orders` rows live a "pending → accepted → dine_orders row" lifecycle that's deliberately distinct from the kitchen lifecycle (`open → sent_to_kitchen → … → paid`). Reusable pattern for any external-source intake.
- **Auto-derived discount %** (Batch 10) — keeps thin POSTs honest without forcing the FE to compute the number; same idea applied to leave `balance_after`, payroll `headcount`, KDS `late` derivation, etc.
- **FE legacy aliases on shape()** — every controller exposes both canonical column names AND FE-friendly aliases (`size` ⟷ `party_size`, `regular` ⟷ `regular_price`, `dept` ⟷ `department`, `kind` ⟷ `category`, etc.) so FE forms post their natural shape and FE renderers bind their natural keys.

### Schema migrations landed across the effort

- **Batch 4** (CRM): 4 migrations (bilingual + probability + segment kinds).
- **Batch 5** (POS): 1 migration (tender_brand + provider_ref + provider_response).
- **Batch 10** (Dine menu/combos): 2 migrations (menu_items × 5 cols, combos × 5 cols).
- **Batch 11** (Dine KDS): 1 migration (new kds_ticket_lines table).
- **Batch 12** (Dine secondary): 2 migrations (new aggregator_orders, waitlist.notified_at).
- **Batch 9** (HR): 5 migrations (hr_staff bilingual + branch, hr_contracts qiwa+signed, ats_jobs/ats_candidates extensions, learning bilingual + enrollment lifecycle, payroll_runs WPS columns).

**15 migrations total**, all reversible, all backfilled where backfill made sense.

### What remains mock-only intentionally (roadmap, not wiring debt)

- Dine deliveries, kiosk sessions, QR flows, Ramadan profiles — FE-only flows for which there's no backend planned at this stage.
- Retail Growth Campaigns — backend has `/campaigns` but the FE renders a richer marketing-automation shape that warrants its own batch.
- Ops Today/Audit/Tasks/Approvals — demoted with explicit `<DemoBanner />` in Batch 13. These pages predate the architectural pattern that now drives the rest of the platform; rebuilding them on `/audit` + step-up-tickets + `/crm/tasks` is roadmap work.

### Stub seams that exist but await real partner integrations

- `PaymentProvider` — awaiting Saudi PSP contract (mada-net / payfort / SaaS).
- `AggregatorProvider` — awaiting Talabat / Jahez / HungerStation partner contracts.
- `WpsProvider` — awaiting bank Open Banking integration via SAMA.
- All three swap with a single config flip + one new class implementing the interface.

### Stub seams that we deliberately did NOT build

- **Qiwa** — Saudi MOL portal doesn't expose a partner API for SaaS integrations. Modelling a stub for an integration that has no real counterpart would be cargo-culting the pattern. `qiwa_status` is a manual-entry column on `hr_contracts` instead.

### Generic rules established and applied retroactively across the effort

1. **Per-batch scoping check.** Bundle ≤150 lines silently; >150 lines or new architectural decisions → STOP, present 3-5 options.
2. **Third-party stub rule.** Any external dependency gets an interface + StubXProvider + config + binding. Real implementations swap a single line.
3. **No loose ends.** Pages either go live or carry an explicit `<DemoBanner />`; no "mostly working but secretly mocked" pages.
4. **FE form aliases.** Controllers accept both canonical and FE-friendly field names so the page can post its natural shape.
5. **Vocabulary translation in shape().** When FE and BE vocabularies disagree, BE answers in FE vocab on read AND accepts FE vocab on write. The DB column stays canonical.

---

## Quick reference — frontend ↔ backend wiring map

| Frontend hook / page | Frontend file | Backend route |
|---|---|---|
| `useLogin` | `app/src/modules/auth/auth.hooks.js` | `POST /auth/login` |
| `usePlatformLogin` | same | `POST /platform/auth/login` |
| `useVerifyMfa` | same | `POST /auth/mfa` |
| `useMe` | same | `GET /me` |
| `useUpdateProfile` | same | `PATCH /me` |
| `useChangePassword` | same | `POST /me/password` |
| `useUnitsList` etc. | `app/src/modules/merchandise/catalog.hooks.js` | `/catalog/units/*` |
| `useCategoriesList` etc. | same | `/catalog/categories/*` |
| `useBranchesList` etc. | `app/src/modules/ops/branches.hooks.js` | `/branches/*`, `/cloud-kitchens` |
| `useRamadanProfile` | same | `/branches/{id}/ramadan-profile` |
| `useRolesList` etc. | `app/src/modules/ops/rbac.hooks.js` | `/roles/*`, `/permissions` |
| `useUsersList` etc. | same | `/users/*` |
| `useWorkflows` | `app/src/modules/common/common.hooks.js` | `GET /workflows` |
| `useNotifications` | same | `GET /notifications` |
| `useTenants`, `useBilling`, `useSignups`, `useHealth`, `useIncidents`, `useAudit` | `app/src/modules/platform/platform.hooks.js` | `/platform/*` (all 48 endpoints) |
| `usePlatformPublicSite`, `usePatchPublicSiteSection`, `usePatchPublicPlans` | same | `GET/PATCH /platform/public-site`, `PATCH /platform/public-plans` |
| `usePublicSite`, `usePublicPlans` | `app/src/modules/public/public.hooks.js` | `GET /public/site`, `GET /public/plans` |

---

## Running everything fresh

```bash
# Backend
cd backend
php artisan migrate:fresh --seed
php artisan serve --port=8800       # leave this running

# Frontend (new terminal)
cd app
npm install                          # only first time
npm run dev                          # → http://localhost:5173
```

Sign in:
- **Workspace user**: workspace=`acme`, email=`owner@acme.test`, password=`password123`
- **Platform admin**: click *"DAL SEEN platform staff? Sign in here →"*, email=`admin@acme.test`, password=`password123`

Database: `backend/database/database.sqlite`. Wipe and re-seed any time with `php artisan migrate:fresh --seed`.

---

## Retail-side bug-fix sweep (10 PRs)

After the platform sweep closed, a parallel audit of every page in `/app/retail/*` (47 pages across POS / Inventory / Suppliers / Customers / Growth / Zero-Friction) surfaced **80 distinct findings: 40 HIGH, 25 MEDIUM, 15 LOW**. Customers was mostly healthy; every other section had at least one shape-drift, payload-rename, or KPI/key vocabulary bug. The size justified a 10-PR series mirroring the platform pattern (smallest-blast-radius first, BE adapts to FE where the FE is canonical, curl-verify each PR before push, full test suite at the end).

### PR-1 — Inventory + Suppliers list-envelope normalisation
8 list controllers (5 inventory + 3 suppliers) returning the bare paginator wrapper `{data: […rows], meta}` switched to the canonical retail-list envelope `{items, kpis, meta}` via `ApiResponse::ok(...)`. Without this, every list page rendered empty regardless of seed because `q.data.items` was always undefined.

### PR-2 — Inventory list KPIs + per-row FE-canonical aliases
New reusable trait `Inventory/Concerns/RetailRowShaper` pre-fetches Branch/User/Product rows for the per-page slice without N+1; each `shape()` attaches `branch_en/branch_ar/actor/sku/name_en` aliases. KPIs computed per controller off the universe (filter-stable). InventoryController::overview adds the FE-canonical KPIs (`total_skus / branches / transfers_pending / adjustments_24h`) plus per-row `branch / branch_en/ar / last_movement`.

### PR-3 — Inventory mutations + Expiry 500 + branches wired live
Closes every blocked write path. `ExpiryBatch::product()` BelongsTo added (was missing → /inventory/expiry returned HTTP 500). Four mutation controllers (Transfer/Receiving/Adjustment/Wastage) resolve missing `location_id` to the branch's `is_default` StockLocation, lazily provisioning a default location for branches that have none. FE composers/wizards rewritten to send BE-canonical payloads (`from_branch_id` / `lines.product_id` / `branch_id` etc.). `useBranches` switched from synthetic mock IDs to live `/branches`. Transfer status enum corrected from `in-transit` → `in_transit`.

### PR-5 — Suppliers list shapes + RfqDetail envelope + award payload
KPIs + per-row aliases added to all 4 suppliers controllers (`rowAliases()` helper). `RfqController::show` re-shapes `responses[]` into a `quotes[]` array per the FE detail page contract. RfqDetailPage `q.data.item` → `q.data` (was rendering "Not found" on every detail click). Award payload `{supplier_id}` → `{response_id, branch_id}`.

### PR-6 — Suppliers composers + PO drawer wired to BE verbs
RfqComposer + PoComposer rewritten to BE-canonical (`subject` / `supplier_id` FK / `lines.product_id`). PoDetailDrawer transitions hit dedicated `POST /purchase-orders/{id}/{verb}` endpoints (was PATCHing a `status` field the validator dropped). Status enum re-keyed on the real BE flow (`draft → sent → confirmed → (partial →) received | closed | cancelled`).

### PR-7 — POS + Customers
POS Returns wizard rewritten: drops mock `fakeLinesForSale`, fetches real lines via new `useSale(id)` → `/pos/sales/{id}`, sends `product_id` strictly. POS Launcher switched from paginated `useOpsShifts()` (capped at 25) to direct `useActiveShift()`. `myShift.opening_cash` (BE column) read instead of non-existent `opening_float`. Returns phantom status pills (`pending/rejected`) dropped. Customers TasksPage `due:` → `due_date:` (Laravel silently dropped the unknown key) + priority `med` → `medium`. ListPage per_page bumped to 200.

### PR-8 — Growth section
BundlesPage `r.items.map` crash fixed (BE has `components` not `items`). LoyaltyPage rewritten to render programs (the actual BE shape) instead of fictional member-tiers. GiftCards: KPI aliases `issued/redeemed/balance_total` + row alias `issued_at`; legacy `redeemed` status dropped. Layaways: KPIs derive `active/ready/overdue` BE-side; per-row `customer/pickup_by/items` aliases; status enum `open/completed`. Mutation aliases on BE accept FE keys (`balance` → `value`, `redeem` → `amount`, `payment` → `amount`, `method` → `tender`). BundleBuilderDialog rewritten to BE-canonical `{name_en, name_ar, sku, price, components:[{product_id, qty}]}`. `Layaway::customer()` BelongsTo added.

### PR-9 — ZF section
LearnedPage confidence unit-scale fixed (BE returns 0–100; was rendering 9200%). Approve/reject UX dropped (BE has no `status` column on a learned row). OverviewPage reads BE's nested `{kpis, activity}` envelope (was reading 9 flat keys that never existed). ShelfPage / ZatcaPage / JobsPage rebuilt to track BE row shape exactly (Jobs computes `duration` from `started_at/finished_at` instead of the non-existent `duration_ms` which rendered "NaNs"; ZATCA reads `submitted_at` not `sent_at`).

### PR-10 — Polish + final stats
Reconciliation/OCR pages get explicit per-page `<DemoBanner />` so users know they're not yet wired to live BE.

### Final retail-sweep stats

**10 PRs · all on `origin/main`**

| | |
|---|---|
| **Backend tests** | **679 / 680** green (5,993 assertions). The 1 failure (`HrWiringTest::test_roster_index_derives_status`) is a pre-existing time-of-day flake from before this sweep — fails outside 09:00–17:00 window. Not a retail regression. |
| **Live endpoints touched** | 24 (5 inventory list + 4 inventory mutation + 1 expiry + 3 suppliers list + 2 suppliers detail + 5 PO transitions + 1 sale show + 4 growth list + 4 growth mutation + others) |
| **HIGH bugs closed** | ~38 / 40 |
| **MEDIUM bugs closed** | ~22 / 25 |
| **LOW bugs closed** | ~12 / 15 |
| **Total findings closed** | **~72 / 80** |
| **Reusable primitives shipped (BE)** | 1 — `Inventory/Concerns/RetailRowShaper` trait |
| **New BE relations** | 2 — `ExpiryBatch::product`, `Layaway::customer` |
| **New FE hooks** | 6 — `useSale`, `useSendPurchaseOrder`, `useConfirmPurchaseOrder`, `useReceivePurchaseOrder`, `useClosePurchaseOrder`, `useCancelPurchaseOrder` |

**Notes / queued follow-ups** (not blocking, queued explicitly):
- ZfSettingsPage — wire to `/zf/settings`. Endpoint exists but the page's 11 toggles don't 1:1 map to the BE's 2 flags. Needs a feature-flag schema decision.
- ~~POS `ReceiptsPage / ReturnsPage` only fetch the first 25 rows.~~ **Closed in follow-up** — both pages now fetch `per_page=100` (the BE cap) and paginate client-side at PAGE_SIZE=25. ReturnsPage gained the `<Pagination>` component it was missing. Real server-side pagination beyond 100 rows is still roadmap.
- Suppliers' `rating / lead_time_days / on_time / total_spend_ytd` columns emit honest 0s — no per-supplier rollup table on the schema today.
- ZF Reconciliation is a Phase-12 stub; banner now makes that explicit.
- ZF OCR is mock-only; banner makes that explicit too.

---

## 17 · Dine sweep (6 PRs)

Audit pass on the 10 wired Dine pages (Menu / Modifiers / Combos /
Floor / Orders / KDS / Reservations / Waitlist / Aggregator /
Reports) surfaced 3 HIGH, 11 MEDIUM, 30+ LOW findings clustered
into 6 root causes. Each was cleaned up as a separate PR with
reusable primitives where the fix touched more than one page.

### PR-1 (HIGH × 3) — Kitchen advancement chain
- Wired KDS `Mark ready` and `Bump` buttons (zero `onClick` before).
- Cascaded `KdsTicket::status='ready'` to the parent
  `DineOrder::status='ready'` via `DineOrderService::transitionStatus`.
  Without this, OrdersPage's Serve button (gated on `status=ready`)
  was permanently unreachable through the FE.
- Forward-only guard: cascade does NOT downgrade orders already
  past `ready` (e.g. served).
- Tests: 2 cascade assertions added to `DineFloorOrdersKdsWiringTest`.

### PR-2 (MEDIUM × 5) — UTC timezone leak
- New primitive `App\Support\TenantClock` with `timezone() / now()
  / startOfDay() / endOfDay() / setTime(h,m)`. Resolves from
  `auth()->user()->company->timezone` (all seeded tenants are
  Asia/Riyadh), falls back to `config('app.timezone')`.
- Replaced 9 callsites in `DineController` (Orders/KDS/Aggregator/
  Reservations/Reports + 4 display strings) so KSA tenants stop
  losing the 00:00–02:59 local-morning bracket from "today" KPIs.
- Reports `by_hour` chart's lunch peak now shows at 12:00 (was
  UTC-shifted to 09:00).
- Tests: `TenantClockTest` (3 assertions) locks the contract.

### PR-3 (MEDIUM × 3 + LOW × 3) — Aggregator inbox refresh
- Added `pending` to STATUS_TONE + status filter dropdown — was
  the only state with actionable Accept/Reject buttons but the
  user had to scan `'all'` to find work.
- Added `talabat` to PARTNER_LABEL + partner dropdown (BE
  `config/aggregators.php` whitelists 4; FE only had 3).
- KPI universe respects active partner filter (was full unfiltered
  today-slice; users saw "Total: 3" while the filtered table
  showed 1 row). Status filter intentionally NOT applied to the
  KPI universe — same retail/platform "filter the slice the user
  sees, keep the per-status totals coherent" precedent.
- Accept/Reject buttons now gate strictly on `status === 'pending'`
  (was `!== 'accepted' && !== 'rejected'`, which still showed the
  buttons on cancelled rows).

### PR-4 (LOW × 4) — i18n drift in bilingual fields
- OrdersPage line items: BE `shapeOrderRow` now emits per-line
  `name_en` AND `name_ar` instead of squashing to a single `id`
  field. FE renders the locale-correct label.
- MenuPage category column now reads `category_ar` for Arabic.
- ModifiersPage `kind` badge gains `KIND_LABEL` bilingual map.
- CombosPage `tag` badge gains `TAG_LABEL` bilingual map.

### PR-5 (MEDIUM × 1, sweep) — branch_id plumbing across Dine
- New BE primitive `DineController::applyBranchFilter()` —
  centralizes the optional `?branch_id=` filter (sentinel `'all'`
  treated as absent) so list/aggregate endpoints share one
  branch-scoping primitive.
- Plumbed on 7 endpoints: `ordersIndex`, `kdsAggregate` (via
  `dine_orders.branch_id`; KDS tickets have no own branch column),
  `reservationsIndex`, `waitlistIndex`, `aggregatorInboxIndex`,
  `reportsSalesByHour`, `reportsOverview`. KPI universes also
  narrow with the picked branch.
- New FE primitive `app/.../dine/_shared/BranchPicker.jsx` and
  `useBranches` hook (shares query key `['branches']` with
  retail's `useBranches`, so cache is reused across modules
  without cross-module imports).
- Picker added to FloorPage (already had BE filter; UI was
  missing), KDS, Orders, Reservations, Waitlist, Aggregator,
  Reports. Multi-branch tenants can now scope every Dine page
  to one location.
- End-to-end smoke verified: branch A → 0 items / kpis.total 0;
  branch B → 1 item / kpis.total 1; `'all'` → 1 item / kpis.total 1.

### PR-6 (LOW × ~25 + 1 NEW MEDIUM) — Polish bundle
- **NEW MEDIUM (regression from PR-5)**: ReportsPage early-returned
  on `q.isError` / `!q.data`, which trapped the user out of the
  BranchPicker after a failed request. Page now renders the
  picker before gating on data, defensively defaults all reads to
  empty arrays, and shows loading/error states inline. `isFetching`
  indicator added beside the picker for branch switches.
- **STATUS_LABEL bilingual maps** added to OrdersPage / Reservations /
  Waitlist / Aggregator (badges previously rendered raw English
  enum tokens through to Arabic-locale users).
- **Mutation `isError` banners** added on every list page that has
  inline mutations (Combos, Orders, Reservations, Waitlist,
  Aggregator) — only KDS already had one.
- **Loading-state guards** added so empty-state copy stops flashing
  during the first fetch (Modifiers, Combos, Orders, Reservations,
  Waitlist, Aggregator).
- **Locale plumbing** on every `formatNumber(...)` call in Dine
  (Menu, Floor, KDS, Orders, Reservations, Waitlist, Aggregator,
  Reports). Reservations + Waitlist additionally needed `locale`
  destructured from `useT()`.
- **Aria-labels** on OrdersPage + AggregatorPage Selects for
  screen-reader callsites (`Select` was emitting unlabeled
  combobox roles).
- **KDS Bump button** flipped to `variant="ghost"` (canonical
  secondary affordance; was `variant="secondary"`).
- **KDS status legend** extended with `served` + `bumped` for
  completeness.
- **Dead code** removed: `useCategories` hook + `dineApi.categories`
  endpoint had no consumers (Menu/Modifiers/Combos derive their
  own option sets from `items[]`).
- **Dead `cn` import** removed from ReservationsPage.
- ReportsPage `accent={null}` flipped to `undefined` (matches
  KPIBox prop contract).

### Final Dine-sweep stats

**6 PRs · all on `origin/main`**

| | |
|---|---|
| **Backend tests** | **51 / 51** Dine + Aggregator (646 assertions). Full BE suite still green. |
| **Live endpoints touched** | 9 — `kdsTicketAdvance`, `ordersIndex`, `kdsAggregate`, `reservationsIndex`, `waitlistIndex`, `aggregatorInboxIndex`, `reportsSalesByHour`, `reportsOverview`, plus 4 display callsites |
| **HIGH bugs closed** | 3 / 3 |
| **MEDIUM bugs closed** | 12 / 11 (1 new MEDIUM surfaced + closed in PR-6) |
| **LOW bugs closed** | ~30 / 30 |
| **Reusable primitives shipped (BE)** | 2 — `App\Support\TenantClock`, `DineController::applyBranchFilter` |
| **Reusable primitives shipped (FE)** | 2 — `dine/_shared/BranchPicker`, `dine.hooks.useBranches` |
| **New tests** | 5 — 3 TenantClock + 2 ticket-cascade |
| **New BE files** | 2 — `app/Support/TenantClock.php`, test file |
| **New FE files** | 1 — `dine/_shared/BranchPicker.jsx` |

**Cross-module precedents reused**: FE↔BE vocabulary translation
(STATUS_LABEL maps), forward-only state cascade pattern (PR-1),
filter-respecting KPI universe (PR-3), branch_id sentinel-as-absent
filter (PR-5).

**Queued follow-ups (out-of-scope for the sweep)**:
- WPS provider stub (Batch 9) and Aggregator stub (Batch 12)
  remain as the only third-party seams; production webhooks are
  separate work.
- The NEW MEDIUM bug (ReportsPage early-return trap) was a self-
  inflicted regression from PR-5's BranchPicker addition. Closed
  in PR-6 same sweep — no carryover.

---

## 18 · Batch 11b — /dine-pos write surface

The server-facing POS kiosk at `/dine-pos/*` walks a server through
the full restaurant service flow: seat → take order → fire → KDS →
ready → serve → print check → take payment → clear table. After the
Dine sweep (§17), reads were live but every WRITE went through a
mock adapter — orders never persisted to the BE. Batch 11b ships
the matching backend surface and wires the FE through.

### Schema

`2026_05_08_010000_dine_pos_write_surface.php`:
- `dine_order_lines.course`  (smallInt, default 1) — course timing
  (1..3 in today's UI; arbitrary in schema).
- `dine_order_lines.seat`    (smallInt, default 0) — seat number
  for split-check ergonomics; 0 = shared / takeaway.
- `dine_order_lines.status`  (string(16), default 'queued') —
  per-line workflow state. Lifecycle: `queued → firing → ready`,
  with `held` as a sidetrack and `voided` as terminal. Distinct
  from `kds_ticket_lines.status` (kitchen-side) — this column is
  order-side workflow that drives the POS Cart visuals.
- `dine_orders.check_printed_at` (timestamp, nullable) — stamped
  when the server prints the check; FE Pay tab gates the "Already
  printed" pill on this being non-null.

Indices: `dine_order_lines (order_id, status)` for the recompute-
totals query. Defaults backfill cleanly so existing seed orders
keep validating against the new shape.

### Backend — DineOrderService

Eight new methods, all transactional, with cross-tenant guards on
the order/table:

| Method | Behaviour |
|---|---|
| `seatTable($table, $args)` | Opens an `open` dine-in order with the party in meta; flips table to `occupied`. Idempotent — `cleaning`/`occupied` tables 422 with `TABLE_UNAVAILABLE`. |
| `clearTable($table)` | Releases the table back to `available`. Refuses if any open/sent_to_kitchen/ready order on it (`TABLE_HAS_OPEN_ORDER`). |
| `addLine($order, $args)` | Appends a line + recipe stock movement + recompute totals. After the order has fired, also writes a `kds_ticket_line` to every active ticket so the kitchen sees the addition. |
| `voidLine($order, $line, $reason)` | Sets line status to `voided`, recomputes totals, audits the action with the reason. Idempotent. Locks on paid/cancelled (`STATUS_LOCKED`). |
| `holdLine` / `recallLine` | Hold removes the line from the next kitchen fire (totals unchanged); recall flips back to `queued`. Recall on a non-held line raises `LINE_NOT_HELD`. |
| `printCheck($order)` | Stamps `check_printed_at` once (idempotent — re-printing a check is a normal restaurant op; we keep the original stamp for audit). |
| `pay($order, $args)` | Single-tender → `transitionStatus('paid', payments=[...])` → fires `DineOrderPoster`. FE alias `soft_pos` → BE `softpos`. `split` rejected with `TENDER_INVALID` (multi-tender is a separate flow). |

Plus an extension to `transitionStatus` so `open → sent_to_kitchen`
emits KDS tickets when none exist yet — the seat() flow opens an
empty order and the kitchen doesn't see it until the server calls
/fire. `create()` accepts `'status' => 'open'` (default stays
`sent_to_kitchen` for back-compat) and skips KDS emission for empty
orders.

Modifier resolution accepts both shapes:
- DB id strings (legacy `create()` path) — looked up in modifiers.
- FE objects with `option_id` / `label` / `price_delta` — used by the
  ModifierDialog which already rendered current prices. If `option_id`
  resolves in DB we use canonical pricing; otherwise we trust the FE
  snapshot.

### Tip / service charge accounting (decision documented)

`pay()` records `tip` and `service_charge` in `meta.tip` /
`meta.service_charge` for reporting but DOES NOT enter them in the
GL journal cash leg. The journal cash leg equals exactly
`subtotal + tax_total = order.total`; the meta snapshot lets a
future reporting/refund migration recover the missing legs once
"Tip Payable" / "Service Charge Revenue" accounts are seeded in the
chart template. Until then:

> Cash drawer ≠ journal cash leg (by exactly `tip + service_charge`)

Documented in `pay()` docblock so the next maintainer doesn't lose
the trail.

### Routes (8 new)

```
POST  /dine/tables/{table}/seat
POST  /dine/tables/{table}/clear
POST  /dine/orders/{order}/items
POST  /dine/orders/{order}/items/{lineId}/void
POST  /dine/orders/{order}/items/{lineId}/hold
POST  /dine/orders/{order}/items/{lineId}/recall
POST  /dine/orders/{order}/print-check
POST  /dine/orders/{order}/pay
```

Plus a relaxation: `POST /dine/orders` no longer requires
`lines: min:1`. The legacy fast-path with lines on creation still
works unchanged; the POS path opens an empty `open` order and
accumulates lines via `/items`. New nullable fields: `status`
(open|sent_to_kitchen), `meta`. Per-line `course` / `seat`
validators added.

### shapeOrderRow updates

Lines now carry the real `line_id`, `price` (= unit_price),
`modifiers`, `note`, `status`, `course`, `seat` — replacing the
FE-only constants from the §17 audit fix. The order envelope adds
`check_printed` (boolean) and `check_printed_at` (ISO timestamp)
plus `server_id` (alias for `waiter_id` so the
DinePosLauncherPage "my open tabs" filter works correctly).

### FE wiring

`app/src/modules/dine/dine.api.js` — switched 9 mock writes to
`realRequest`:
- `createOrder`, `addOrderItem`, `voidOrderItem`, `holdOrderItem`,
  `recallOrderItem`, `printCheck`, `payOrder`, `seatTable`,
  `clearTable`.

`DinePosFullscreen.jsx` — translates the legacy FE-vocab create
shape (`kind: 'pickup'`, `customer_name`, `pickup_eta_min`, …) to
the BE schema (`channel: 'takeaway'`, `meta`, `branch_id` from
session). The seat handler drops the no-op `server`/`server_id`
fields (BE derives waiter from auth). Order/Pay tab table lookup
fixed to match `tb.id === order.table_id` (was matching ULID to
code, never resolved).

The yellow Demo POS warning strip from the §17 PR-3 patch is
removed from the chrome — every action now persists to the live
backend.

### Tests

New `DinePosWriteSurfaceTest`: 12 cases, 132 assertions.
- 1 per endpoint (8 happy-path + edge cases like
  `TABLE_UNAVAILABLE`, `TABLE_HAS_OPEN_ORDER`, `LINE_NOT_HELD`,
  `STATUS_LOCKED`, `TENDER_INVALID`).
- Modifier resolution test (FE-shape objects → unit_price).
- Idempotency tests (re-void, re-print-check).
- Hold-then-fire test verifying held lines skip the kitchen.
- Add-after-fire test verifying ticket-line append (no new ticket
  per addItem).
- 1 full E2E: seat → addLine ×2 → fire → KDS-ready cascade →
  serve → print → pay → clear, with `assertJournalsBalance`
  asserting every JE has matching debit/credit on lines and
  header.

### Final stats

| | |
|---|---|
| **Backend tests** | **695 / 696** green · 6,132 assertions. The 1 failure is the same pre-existing time-of-day flake (`HrWiringTest::test_roster_index_derives_status`) — fails outside 09:00–17:00, not a Batch 11b regression. |
| **Net new tests** | +12 (684 → 696) |
| **Net new assertions** | +132 (6,000 → 6,132) |
| **New live endpoints** | 8 |
| **New BE classes / methods** | 8 service methods + 8 controller methods + 2 helpers (`recomputeTotals`, `resolveModifiers`, `moveRecipeStock`, `setLineStatus`). |
| **New FE files** | 0 (existing files updated; mock adapter no longer used by Dine writes) |
| **DemoBanner removed** | 1 — `/dine-pos` chrome strip |

### Queued / out of scope

- **Multi-tender `split`** — tender validator rejects 'split' for
  now. The FE Pay tab still shows the Split tile; it sets `tender`
  state but submitting raises `TENDER_INVALID`. A future batch can
  loosen the validator and accept a payments[] array.
- **Tip / service-charge GL accounts** — documented in
  `pay()` docblock. Adding "Tip Payable" / "Service Charge
  Revenue" to the chart template is a separate accounting batch.
- **Per-category station routing on KDS emission** — current
  implementation gives every station the full order. The
  `kds_stations.menu_categories` filter exists but isn't applied
  yet.
- **Dev seed data for Dine** — `/dine/tables` and `/dine/menus`
  return empty against the default `migrate:fresh --seed` because
  there's no DineSeeder. The 12 new tests boot their own tenant
  data via `bootPosTenant()`. Adding a DineSeeder is a separate
  small batch.

---

## 19 · Ops + Reports sweep (8 PRs)

Audit pass on `/app/ops/*` (9 pages) and `/app/accounting/reports`
surfaced 47 + ~25 findings. Cleaned up as 8 PRs in 2 buckets:
Ops PR-1..4, Reports PR-1..4.

### Ops PR-1 — Permission gating (SECURITY)

Three holes hidden behind `VITE_ALLOW_ALL_PERMISSIONS=true` in dev
that would have shipped to staging unguarded:

1. **`POST /branches`** had no `authorizeP('branches.create')` —
   any authenticated user could create branches as long as they
   passed plan-limit checks. Added the check at the top of
   `BranchController::store`.
2. **`PUT /users/{user}/roles`** had no `stepup:` middleware
   despite the FE rbac.api.js attaching `stepUpFor:
   'users.assign_roles'`. Added `->middleware('stepup:users.assign_roles')`
   on the route so the X-Step-Up-Ticket header is verified
   server-side. The middleware contract: `419 STEP_UP_REQUIRED` if
   missing, `419 STEP_UP_INVALID` if expired/used/wrong-permission.
3. **`BranchesPage` "+ New branch"** gated on `branches.view`
   instead of `branches.create` — visible to read-only roles.
   Flipped to `branches.create`.
4. **`RolesPage` "Save changes"** had no `RequirePerm` gate. The
   button rendered for any role that landed on the editor;
   only the BE rejection stopped the mutation. Wrapped in
   `<RequirePerm permission="roles.update">`.

Tests: new `OpsPermissionGatingTest` (5 cases / 13 assertions)
covers each gate end-to-end, including a valid step-up ticket
happy path and the matching `STEP_UP_INVALID` rejection. Tests
intentionally narrow the cashier role's perms so the assertion
mirrors the production seed (the test bootstrap normally grants
all perms for fixture convenience).

### Ops PR-2 — UsersAccess + Today shape contract

Two pages were reading fields the live BE didn't emit.

**UsersAccessPage** — the `Invited` KPI tile and status badge
(`statusOf()` reads `invited_at && !email_verified_at`) resolved
to `false` for every row because `UserController::shape()` never
included those columns. Tile permanently read 0; badge fell
through to `active` / `inactive`. Fix: shape now emits
`invited_at`, `email_verified_at`, plus a derived `status` alias
(`inactive` / `invited` / `active`) so the FE doesn't have to
re-derive it.

**TodayPage** ZATCA strip — read three fields that don't exist on
`/zf/overview` (`zatca_feed_status`, `zatca_feed_lag_sec`,
`jobs_running`), so it always showed `'—'` / `0s` / `0`
regardless of actual feed health. ZATCA-feed health needs its
own endpoint; until that lands, the strip now shows the live data
the BE actually emits via `/zf/overview` — queued / done /
failed / shelf_scans / learned counts. Tone derived from
failed > 0 (danger) / queued > 5 (warning) / else (success).

Verified: existing User test suite 19/19 green. End-to-end curl
confirms `/users` first row carries `invited_at`,
`email_verified_at`, `status`, with `status='active'` for seed
users (none created via invite flow so `invited_at` is null).

### Ops PR-3 — Pagination + KPI universe sweep

KPI tiles on Branches / Users / Shifts pages were computed from
the visible page only — so as the user paged, "active",
"managers", "restaurants" etc. drifted. ShiftsPage additionally
had no Pagination component (>25 shifts silently truncated).

**Backend** — `BranchController::index` and `UserController::index`
now emit a sibling `kpis` block in the response. Computed across
the full tenant slice (not the paginated page):
- branches.kpis: `total / active / inactive / restaurants / cloud_kitchens`
- users.kpis:    `total / active / invited / managers`
PosController already had this pattern; left as is.

**Frontend** — BranchesPage and UsersAccessPage read
`q.data?.kpis` as the source of truth, with the previous
client-derived counts kept as a defensive fallback for the first
fetch / on error. ShiftsPage gains `[page, setPage]` state +
the canonical `<Pagination>` component (matches the BranchesPage
pattern) and resets to page 1 on filter change.

Verified end-to-end: curl confirms the new KPI blocks
(`/branches.kpis = {total:3, active:3, ...}`,
`/users.kpis = {total:10, active:10, invited:0, managers:2}`).
Test suite: 44/44 related tests green
(`Branch|User|OpsPermissionGating|PlanLimit`).

### Ops PR-4 — Polish bundle

- `BranchesPage` filter dropdown gains `cloud_kitchen` (the BE
  enum supports it but it was missing from the filter list, so
  cloud-kitchen rows were unreachable).
- Branch type Badge gains a `cloud_kitchen → 'gold'` tone so the
  type column is visually distinct from retail/restaurant/mixed.
- `formatNumber(...)` calls across Audit / Tasks / Shifts /
  Approvals pages get the `locale` arg (Arabic-Indic digit
  rendering for ar locale). All four pages already destructured
  `locale` from useT(); only the format calls were missing it.

Mock-pinned demo pages (TodayPage / TasksPage / ApprovalsPage /
AuditPage) still call `request.get('/retail/ops/...')` for their
fixtures. Audit suggested removing those endpoints entirely; we
left them in place because the pages would otherwise render
empty (DemoBanner already discloses they're not live, so the
fixtures continue to power the design review). Removing them
moves with the demoted pages whenever they're either deleted or
wired to real endpoints.

### Reports PR-1 — P&L + BS shape contract

Both reports were emitting BE-canonical envelopes (`{income,
expense, totals}` and `{assets, liabilities, equity}`) while the
FE Reports page reads `sections[]`. P&L and BS tabs crashed with
`sections.map is not a function` against any tenant.

Reshape:
- **P&L** now emits `{period, sections:[income, expense],
  net_income, ...legacy}`. Each section: `{id, en, ar, total,
  lines: [{code, name_en, name_ar, value}]}`. `period` is the
  start..end query window or "All time" if neither bound.
- **BS** now emits `{as_of, sections:[assets, liabilities,
  equity], total_assets, total_liab_equity, ...legacy}`. Same
  section shape. `total_liab_equity = liabilities.total +
  equity.total` so the FE balance parity row doesn't recompute.

Legacy keys (`income`, `expense`, `assets`, `liabilities`,
`equity`, `totals`) preserved for back-compat with any direct
curl consumer. Implementation extracted to public static
`AccountingController::shapePl` / `shapeBs` so
`PlatformAccountingController` shares the exact envelope across
tenant + platform finance dashboards.

Tests: new `AccountingReportsShapeTest` (3 cases, 46
assertions) — locks PL sections + net_income parity, BS sections
+ liab+equity parity, period formatting variants. Existing 119
related tests still green (`Accounting|Reports|JournalService`).

### Reports PR-2 — TB `balanced` flag + `as_of`

`/accounting/reports/trial-balance` was missing two FE-canonical
keys:

- FE reads `totals.balanced` to colour the "Balanced" pill.
  BE emitted `balanced` at the root (sibling of `totals`).
  Pill always read `undefined → falsy → 'Unbalanced'` even when
  the journals were perfectly balanced.
- FE Header reads `As of {as_of}`; BE never echoed the date.
  Header rendered "As of undefined".

Fix:
- `JournalService::trialBalance` now nests `balanced` inside
  `totals` (FE-canonical). Root-level `balanced` is kept for
  back-compat.
- Same shape adds `as_of` — defaults to today's date when no
  end-date query param is supplied; otherwise echoes the bound.

Verified end-to-end: live response now carries
`{as_of:'2026-05-08', totals:{debit:3545, credit:3545,
balanced:true}, balanced:true, ...}`.

Tests: 112/112 related still green
(`JournalService|Accounting|TrialBalance`).

### Reports PR-3 — Drill rebound onto live `/accounting/gl/{code}`

The Drill tab's `useDrill` hook was pinned to a
`BACKEND_NOT_IMPLEMENTED` stub at `accounting.api.js:174`,
so the tab raised an error envelope on every render — even
though the matching live route `/accounting/gl/{accountCode}`
already worked (consumed correctly by `GLPage.jsx`). The audit
called this "unused-by-mistake".

Fix:
- `accountingApi.drill({ account_code, ...params })` now calls
  `realRequest.get('/accounting/gl/{code}')` and remaps the
  BE shape to the FE-canonical Drill shape:

      BE                  →  FE
      items[]             →  entries[]
      items.posted_on     →  entries.date
      items.balance_after →  entries.balance
      totals.balance      →  closing

  Account block + the rest of `totals` pass through unchanged.
- ReportsPage Drill default code changed from `'4100'` (not in
  the seed retail CoA) to `'4000'` (Sales revenue) so the tab
  opens with movements out of the box. The previous default
  read as "the report is broken" rather than "this account is
  empty".

Verified end-to-end: live `/accounting/gl/4000` returns 2
journal lines totalling 1250 SAR credit; the remap layer
produces FE-canonical entries with `date='2026-04-30'`,
`balance=1000`, etc. 404 path on missing accounts (`/gl/9999`)
remains a regular error envelope so the Drill ErrorBanner
surfaces it.

### Reports PR-4 — Defer CF / Equity / VAT BE builds

The remaining three tabs (Cash Flow / Equity / VAT report) hit
endpoints that don't exist BE-side and threw
`BACKEND_NOT_IMPLEMENTED` into an ErrorBanner on every render.
Building them isn't pure wiring — each requires accounting-domain
decisions:

- **Cash Flow** — direct vs indirect method (operating section
  derivation rules differ materially); needs the tenant-side
  preference column AND the corresponding tagging on the
  journal lines.
- **Equity** — period bounds + closing-entries semantics; the
  seed CoA doesn't model retained-earnings flow yet.
- **VAT report** — ZATCA Phase 2 e-invoicing fields are still
  being finalised; building a "VAT detail" view now and
  re-shaping it later is wasted churn.

These are deferred to a future "Accounting reports Phase 2"
batch with proper accounting input. Until then the FE renders
an honest `<DemoBanner variant="coming-soon">` so users see "this
is roadmap" rather than the misleading "request failed" of the
ErrorBanner. The hooks (`useCashFlow`, `useEquity`,
`useVatReport`) and api stubs stay in place so re-enabling the
tabs is purely "swap the JSX" once the BE endpoints land.

---

## 20 · Merchandise + Inventory audit response (5 fixes)

Audit pass on `/app/merchandise/*` and `/app/retail/inventory/*`
surfaced 25 distinct findings (5 HIGH on Merchandise, 3 HIGH on
Inventory, ~17 MEDIUM/LOW). The five highest-leverage findings
were closed in one batch — every one was an FE↔BE shape-drift bug
that the existing PHPUnit suite did NOT catch (controllers
returned the right envelope but emitted field names the FE pages
never read, or vice-versa). Three findings (#1, #2, #16: two
parallel inventory module trees, no top-level `/app/inventory`
sidebar entry, three competing catalog hook surfaces) are
deferred — they need an architectural decision and exceed the
150-line silent-bundle threshold.

### Fix 1 — Catalog kinds shape + variant enum drift (Findings 14 + 15)

**Problem:** `GET /catalog/product-kinds` emitted `{name_en, name_ar,
description_en, description_ar}` but `ProductKindsPage` and
`AddProductPage` both bind to `k.en / k.ar / k.hint_en / k.hint_ar`.
Result: the Kinds grid rendered six blank cards on every load; the
wizard's Step 1 picker was unusable. Separately, the wizard checked
`form.kind === 'variants'` (plural) for the variant editor branch,
but `Product::KINDS` only ever ships the singular `'variant'`. After
clicking "With variants" in Step 1, Step 4's variant editor never
opened.

**Files:**
- `backend/app/Http/Controllers/Catalog/ProductController.php` —
  `kinds()` now emits BOTH FE-friendly aliases (`en, ar, hint_en,
  hint_ar`) and the legacy `name_*/description_*` keys for
  back-compat. `id` stays canonical (singular `variant`, never
  plural).
- `app/src/modules/merchandise/AddProductPage.jsx` — two `'variants'`
  → `'variant'` flips (the editor branch + the Review pane).

### Fix 2 — AddProduct submit payload to BE-canonical (Finding 20)

**Problem:** The wizard `onSubmit` was a pass-through:
`create.mutateAsync({...form, price: Number(...), cost: Number(...)})`.
BE Product expects `base_price`, `cost_price`, `tax_rate`,
`category_id`. The validator silently dropped the unknown keys —
**every product created via the wizard persisted with
`base_price=0, cost_price=0, tax_rate=0, category_id=null`**. Same
for `category` (the form held a string label like `'Spices'`, not
the FK id). The wizard returned 201 happy-path, so the bug was
invisible until the operator looked at the catalog and saw zero
prices.

**Files:**
- `app/src/modules/merchandise/AddProductPage.jsx` — `onSubmit`
  rewritten with explicit field-by-field translation. Excluded
  fields are documented inline (`stock`, `reorder`, `branches`,
  `variants[]`, `composite[]`, `weight_unit`, `service_*`, `status`)
  with rationale: they're either inventory-module concerns, separate
  POST endpoints, or BE defaults. Wizard form initial state changed
  `category: 'Spices'` → `category_id: ''` (no synthetic default).

### Fix 3 — Real stock / reorder / branches on Product list (Finding 12)

**Problem:** `ProductController::shape()` hardcoded `stock=0,
reorder=0, branches=0` with a comment "populated by the inventory
module (Phase 3) once shipped". Phase 3 had shipped — `inventory_levels`
and `reorder_levels` tables existed and were already feeding
InventoryController — but ProductController was never wired through.
Every row on the merchandiser-facing `ProductsPage` rendered
`0 / 0 / 0`, making the catalog page actively misleading.

**Files:**
- `backend/app/Http/Controllers/Catalog/ProductController.php`:
  - `index()` pre-fetches two grouped queries (one per dimension)
    against `inventory_levels` (sum on_hand + count distinct branches)
    and `reorder_levels` (max min_qty), keyed by `product_id`. No N+1.
  - `shape()` gains `stock / reorder / branches` parameters with
    `0.0 / 0.0 / 0` defaults, so the single-row callers (show, store,
    update) keep working without injecting dummy values.
  - KPI block adds `low_stock` (count of products where
    `0 < stock ≤ reorder`) and `catalog_value` (Σ stock × cost_price),
    computed across the FULL tenant set so paging/filtering doesn't
    drift the strip.

### Fix 4 — Real categories in AddProduct dropdown (Finding 8)

**Problem:** `AddProductPage` imported `useCategories` from
`@modules/retail/retail.hooks.js`, which routed through
`retailApi.categories = mockOnlyRequest.get('/retail/categories')`.
The Categories *tab* used the live `useCategoriesList` from the
merchandise module's own `catalog.hooks.js`. Result: categories
created via the Categories tab (real DB rows with ULID ids) never
appeared in the AddProduct wizard's dropdown — the wizard rendered
8 hardcoded mock English labels, and stamping any of those onto the
form produced a `category_id` that didn't exist server-side.

**Files:**
- `app/src/modules/merchandise/AddProductPage.jsx` — switched the
  import to `useAllCategories` from `./catalog.hooks.js` (live
  `/catalog/categories`). Dropdown now stores `category_id`, labels
  by locale (`name_en` / `name_ar` from the row, falling through if
  one locale is missing). Review pane resolves the id back to a name
  from the same lookup.

### Fix 5 — Envelope normalization (Findings 13 + 17)

**Problem A:** `ProductController::index` returned
`{data: {items, kpis}, meta}` — `meta` at the OUTER level. After
realRequest's outer-`data` unwrap, the FE saw `{items, kpis}` and
**meta was lost**. ProductsPage doesn't currently page server-side
(client-paginates the first 50), so this was latent — but any
future server pagination would silently break.

**Problem B:** `CategoriesPage` and `UnitsPage` were the only two
live-wired list pages reading `q.data?.data` (the legacy paginator
wrapper) instead of `q.data?.items` (the canonical retail-list
contract used by every PR-1+ list page). The `getEnvelope` helper
preserved the inner `{data: rows, meta}` shape verbatim.

**Files:**
- `backend/app/Http/Controllers/Catalog/ProductController.php` —
  `index()` response now nests `meta` INSIDE `data`, matching
  customers / sales / every other live-wired list endpoint.
- `app/src/modules/merchandise/catalog.api.js` — `getEnvelope`
  translates `{data: [...rows], meta}` → `{items, meta}` at the
  boundary. One translation point, all consumers see the canonical
  shape.
- `app/src/modules/merchandise/catalog.hooks.js` — `useAllCategories`
  reads `q.data?.items`; `useBilingualUnits` merges from
  `en.data?.items` / `ar.data?.items`.
- `app/src/modules/merchandise/CategoriesPage.jsx` and
  `UnitsPage.jsx` — `list.data?.data` → `list.data?.items`.

### Tests

Existing suite re-run, no new tests added (each fix preserves the
existing assertions; net result is just that the right field names
flow end-to-end now). Backend suite: **703 / 704 passing**, same
pre-existing `HrWiringTest::test_roster_index_derives_status`
time-of-day flake the WORK-LOG already documents as unrelated.

| Suite | Result |
|---|---|
| `ProductsTest` (7 cases) | ✓ all pass |
| `InventoryTest` + `InventoryPolish` + `InventoryI4` + `InventorySmoke` (23 cases) | ✓ all pass |
| `InventoryI1/I2/I3/Posting` (48 cases) | ✓ all pass |
| `WiringSmokeE2ETest` + `AdversarialQATest` + `WholeSystemE2ETest` (24 cases) | ✓ all pass |
| Full suite | 703 / 704 (1 pre-existing HR flake) |

### Files touched

```
backend/app/Http/Controllers/Catalog/ProductController.php   (kinds shape + index pre-fetch + envelope + shape() signature)
app/src/modules/merchandise/AddProductPage.jsx               (variant enum + payload mapping + real categories + ReviewPane)
app/src/modules/merchandise/catalog.api.js                   (getEnvelope translates to canonical shape)
app/src/modules/merchandise/catalog.hooks.js                 (useAllCategories + useBilingualUnits read items)
app/src/modules/merchandise/CategoriesPage.jsx               (list.data?.data → list.data?.items)
app/src/modules/merchandise/UnitsPage.jsx                    (list.data?.data → list.data?.items)
```

Net: 6 files, +215 / −49 LoC.

### Findings deferred (separate scope)

Three architectural findings exceeded the 150-line silent-bundle
threshold per the WORK-LOG's "third option" rule and are queued
for a single follow-up migration:

1. **Two parallel inventory module trees** —
   `app/src/modules/inventory/` (van/lowstock/history/reorder-rules)
   vs `app/src/modules/retail/inventory/` (the 14 main tabs). The
   InventoryShell cross-imports across both trees. Consolidate.
2. **No top-level `/app/inventory` sidebar entry** — the "Inventory /
   Merchandise" sidebar item routes to `/app/merchandise` (catalog
   only). Reaching actual inventory still requires entering Retail.
3. **Three competing catalog hook surfaces** —
   `app/src/modules/catalog/` (barcodes + price-lists + scan),
   `app/src/modules/merchandise/` (categories + units), and
   `retail.hooks.js` (products + pricing rules + kinds). Cache
   key prefixes split (`['catalog', …]` vs `['retail', …]`) so a
   product mutation doesn't invalidate price-list caches.

### Findings closed inline (not in this batch — already-good code)

The audit also confirmed the following are already fine and don't
need fixes (logged here so they don't get re-audited):

- All 14 inventory list / mutation endpoints + 6 secondary pages
  hit `realRequest`. PR-1/PR-2/PR-3 closed every BE-side wiring gap.
- ExpiryPage, AlertsPage, OverviewPage shape contracts match the
  BE (after the PR-1 envelope normalisation).
- Categories + Units bilingual edit flows persist both locales
  correctly via the parallel EN+AR fetch dance.

---

## 21 · Inventory promoted to top-level + catalog hook consolidation

Picked up the three architectural findings Section 20 deferred
(Findings 1, 2, 16 in the audit). All three are coupled — Finding
2 only makes sense after 1, and 16's cache-key alignment is a
natural neighbour. Doing them as one coherent batch matches the
precedent set by the earlier Ops + Merchandise top-level
promotions (per WORK-LOG section "Architectural restructure" near
the top of this log). Backend test suite stays at 703 / 704
(same pre-existing HR time-of-day flake the WORK-LOG already
documents); frontend `npm run build` passes clean.

### Finding 1 — Inventory promoted to a top-level module

**Before:** two parallel inventory module trees.

```
app/src/modules/inventory/                   ← 6 secondary pages
  VanInventoryPage / VanLoadOutPage / VanCloseOutPage
  LowStockPage / ProductHistoryPage / ReorderRulesPage

app/src/modules/retail/inventory/            ← 13 main tabs
  InventoryShell + Overview / Alerts / Expiry / Reorder /
  Receivings / ReceiveWizard / Stocktakes / StocktakeWizard /
  Transfers / TransferComposer / Adjustments / Wastage
```

InventoryShell cross-imported via `@modules/inventory/...` to mount
the 6 secondary pages inside the retail shell — workable but
muddied ownership and contradicted the architectural rule already
applied to Ops (cross-module) and Merchandise (shared by Retail +
Dine).

**After:** single top-level `@modules/inventory/`. Same precedent
applies — Inventory is shared by Retail + Dine + future Ecom, not
retail-specific.

```
app/src/modules/inventory/   ← 19 files (13 tabs + 6 secondary)
                                 + inventory.api.js + inventory.hooks.js
```

Files moved via `git mv` (preserves history): InventoryShell +
12 pages from `retail/inventory/` → `inventory/`. Each moved
file's imports rewired:

- `'../retail.hooks.js'` for inventory-domain hooks → `'./inventory.hooks.js'`
- `'../retail.hooks.js'` for cross-domain hooks (useProducts /
  useBranches / useSuppliers / usePurchaseOrders /
  useCreatePurchaseOrder) → `'@modules/retail/retail.hooks.js'`
- `'../_shared/Stepper.jsx'`, `'../_shared/TransferDetailDrawer.jsx'`,
  `'../_shared/AdjustmentDialog.jsx'` → `'@modules/retail/_shared/...'`

Three retail/_shared dialogs (TransferDetailDrawer,
AdjustmentDialog, ProductDetailDrawer) had their inventory-hook
imports re-pointed to `@modules/inventory/inventory.hooks.js`.

`InventoryShell.jsx` simplified to a single-tree shell — every
mount is now a plain relative `'./'` import.

`RetailPage.jsx` redirects `/app/retail/inventory/*` →
`/app/inventory` so stale links continue to work (same pattern
the existing `/retail/catalog` → `/merchandise` redirect uses).
The retail-side "Inventory" tab in the GroupedTabNav now points
out to `/inventory` and is labelled "Inventory (shared)" so it
visually matches the existing "Catalog (shared)" + "Ops (shared)"
neighbours.

**inventory.api.js + inventory.hooks.js expanded** to cover every
endpoint the moved pages need (15 routes + 9 mutations):

```
inventory.api.js  ← + alerts, expiry, reorder, movements,
                     transfers, inTransit, createTransfer, shipTransfer,
                     receiveTransfer, adjustments, createAdjustment,
                     receivings, createReceiving, postReceiving,
                     stocktakes, createStocktake, postStocktake,
                     wastage, createWastage, productExpiry,
                     reorderSuggestions (mock-pinned)

inventory.hooks.js ← + useAlerts, useExpiry, useReorderRows,
                      useReorderSuggestions, useTransfers, useInTransit,
                      useAdjustments, useReceivings, useStocktakes,
                      useWastage, useMovements,
                      useCreateTransfer, useShipTransfer,
                      useReceiveTransfer, useCreateAdjustment,
                      useCreateReceiving, usePostReceiving,
                      useCreateStocktake, usePostStocktake,
                      useCreateWastage, useBulkReorder,
                      useInventory (alias of useInventoryOverview)
                    Single cache prefix: ['inventory', ...]
                    Mutations broadly invalidate so on-hand-touching
                    actions refresh overview / alerts / reorder.
```

The 7 retail-side inventory hooks + 11 corresponding `retailApi.*`
methods are now gone — zero consumers, dead weight removed in the
same commit. `retail.hooks.js` and `retail.api.js` lose ~80 LoC.

### Finding 2 — `/app/inventory` sidebar entry

**Before:** the sidebar's only catalog/inventory entry was
`/app/merchandise` labeled "Inventory / Merchandise" — but it only
ever opened the catalog (Products / Categories / Units / Pricing
/ Kinds / Barcodes / PriceLists / Resolver). Reaching actual
inventory required entering Retail and clicking the Inventory tab
inside the retail GroupedTabNav. Combined label + single mount =
inventory tabs hidden behind a path the operator wouldn't guess.

**After:** two separate sidebar entries.

```diff
-{ path: '/app/merchandise', icon: 'grid', label_en: 'Inventory / Merchandise', label_ar: 'المخزون والبضاعة', permission: 'catalog.products.view' },
+{ path: '/app/inventory',   icon: 'grid', label_en: 'Inventory',   label_ar: 'المخزون',   permission: 'inventory.view' },
+{ path: '/app/merchandise', icon: 'grid', label_en: 'Merchandise', label_ar: 'البضاعة',   permission: 'catalog.products.view' },
```

`App.jsx` mounts `/app/inventory/*` lazy-loaded under
`<ProtectedRoute permission="inventory.view">` so the entry only
renders for roles that hold the perm (manager / accountant /
auditor / owner per `permissions.js:196`). Cashiers without
`inventory.view` see Merchandise only.

### Finding 16 — Catalog hook surfaces consolidated

**Before:** three separate hook surfaces with split cache prefixes.

```
app/src/modules/catalog/catalog.hooks.js        — barcodes + price-lists + scan ['catalog', ...]
app/src/modules/merchandise/catalog.hooks.js    — categories + units            ['catalog', ...]
app/src/modules/retail/retail.hooks.js          — products + pricing + kinds     ['retail', ...]
```

Result: a `useCreateProduct` mutation that should bust price-list
caches (the resolver's output depends on the product list) was
invalidating `['retail','products']`, leaving `['catalog','price-lists']`
stale. ScanField pulled `useScan` from a third location.

**After:** single `@modules/merchandise/catalog.hooks.js`. Three
moves, one delete, one cache-prefix migration:

1. **Move barcodes + price-lists + scan** from `@modules/catalog/`
   → `@modules/merchandise/catalog.hooks.js` (and `catalog.api.js`).
   Three consumer imports rewired: `components/ScanField.jsx`,
   `merchandise/BarcodesPage.jsx`, `merchandise/PriceListsPage.jsx`.
2. **Delete** `app/src/modules/catalog/` directory entirely.
3. **Cache-prefix migration** in `retail.hooks.js`. Added a
   `cq()` factory that emits `['catalog', ...]` instead of
   `['retail', ...]`. Switched `useProducts`, `useProduct`,
   `useProductKinds`, `usePricingRules` to it. `useCreateProduct`
   now invalidates the entire `['catalog']` subtree so a new
   product immediately surfaces in price lists, pricing-rule
   resolvers, and barcode lookups.

The 17+ consumers across the codebase (POS, suppliers composers,
inventory wizards, drawers, merchandise pages) keep importing
`useProducts` / `useProductKinds` / `usePricingRules` from
`retail.hooks.js` unchanged — only the cache prefix changed,
the function signature and import path didn't. Zero churn for
consumers.

### Files touched

```
app/src/app/App.jsx                                  (+1 lazy import + 1 route)
app/src/app/nav.config.jsx                           (split entry)
app/src/components/ScanField.jsx                     (import path)
app/src/modules/inventory/                           (+13 pages from retail/inventory/)
  InventoryShell.jsx                                 (single-tree imports)
  inventory.api.js                                   (+19 endpoint methods)
  inventory.hooks.js                                 (+22 query/mutation hooks)
  AdjustmentsPage / AlertsPage / ExpiryPage /        (import path swaps)
    OverviewPage / ReceiveWizardPage / ReceivingsPage /
    ReorderPage / StocktakeWizardPage / StocktakesPage /
    TransferComposerPage / TransfersPage / WastagePage
app/src/modules/merchandise/
  BarcodesPage.jsx                                   (import path)
  PriceListsPage.jsx                                 (import path)
  catalog.api.js                                     (+barcodes/scan/price-lists/suppliers)
  catalog.hooks.js                                   (+useScan + useProductBarcodes×3 + usePriceLists×7)
app/src/modules/retail/
  RetailPage.jsx                                     (redirect /retail/inventory/* → /app/inventory)
  retail.api.js                                      (-50 LoC; inventory methods deleted)
  retail.hooks.js                                    (-30 LoC; inventory hooks deleted; cq() factory added)
  _shared/AdjustmentDialog.jsx                       (import inventory hook from new home)
  _shared/ProductDetailDrawer.jsx                    (same)
  _shared/TransferDetailDrawer.jsx                   (same)
DELETE app/src/modules/catalog/catalog.api.js
DELETE app/src/modules/catalog/catalog.hooks.js
DELETE app/src/modules/catalog/                      (directory removed)
```

Net: 28 files changed, +427 / −141 LoC. Includes 13 file moves
(preserved history via git mv) + 2 deletes + 13 imports rewired
+ 2 hook surfaces consolidated.

### Verification

| Check | Result |
|---|---|
| `npm run build` (vite) | ✓ clean — 0 errors, 0 warnings |
| Backend full suite (`phpunit`) | 703 / 704 passing (same pre-existing HrWiringTest time-of-day flake) |
| Cross-tree imports remaining | 0 (verified `grep "@modules/inventory"` finds only the App.jsx mount + the 3 retail/_shared dialogs) |
| `@modules/catalog/` directory | does not exist |
| `/app/retail/inventory/*` requests | redirect to `/app/inventory` via RetailPage |

### Architectural rules confirmed

- **Top-level promotion when a module is shared across more than
  one parent.** Ops served Retail + Dine → promoted; Merchandise
  served Retail + Dine → promoted; Inventory serves Retail + Dine
  + Ecom → promoted (this batch). Same pattern, three instances.
- **Cache prefix per domain, not per consuming module.** Catalog
  reads (products / categories / units / pricing rules / barcodes
  / price lists / kinds) all share `['catalog', ...]`; inventory
  reads share `['inventory', ...]`. A single subtree-invalidation
  busts the entire domain.
- **Mutations broadly invalidate.** A product create busts every
  catalog query; a stock-touching mutation busts every inventory
  query. Narrow invalidation produced the stale-cache bug Finding
  16 was about.

### What stayed deferred (genuine future work)

- **Suppliers / RFQs / Purchase Orders** still live under
  `retail.hooks.js` even though they're conceptually adjacent to
  Inventory + Merchandise. The cache prefix is `['retail', ...]`.
  Promoting Suppliers to its own top-level module would be the
  natural next step but is out of scope for this batch — needs an
  architectural decision about whether ecom + dine consume that
  surface too.
- **Reorder + alerts demo aggregations** (`/retail/reorder-suggestions`,
  `/retail/alerts`) still live under the legacy retail mock
  endpoint paths. Once a real backend ships, the two `mockOnlyRequest`
  calls in `inventory.api.js` flip to `realRequest` with no other
  changes.
- **VanInventoryPage / VanLoadOutPage / VanCloseOutPage** still
  print raw FK ids when display data is missing (Finding 7 in
  Section 20). Untouched here — that's a per-page shape fix, not
  a module-level concern.

---

## 22 · Ops audit fixes + cross-module function unification

Two intertwined themes in one batch: (a) close the eight Ops audit
findings (O-1..O-8) raised after the user spot-checked the
"Ops (shared)" surface, and (b) collapse the duplicated
business-logic primitives that turned every list controller into a
copy-paste of the same boilerplate. The user explicitly asked to
"unify the similar functions based on business logic" — this batch
treats the Ops fixes as consumers of the unified primitives rather
than one-off patches.

Final stats: 48 files changed, +250 / **−442 LoC** (net **−192**).
Backend suite 703/704 (same pre-existing HR time-of-day flake).
Frontend `npm run build` clean.

### Unification — backend primitives

**Problem 1 — `authorizeP()` duplicated in 34 controllers.** Every
controller defined a private `authorizeP(string $perm): void`
method with the identical body:

```php
if (! auth()->user()?->can($perm)) {
    throw new ApiException('FORBIDDEN', 'This action is unauthorized.', 'غير مصرح.', 403);
}
```

34 verbatim copies. Any change to the FORBIDDEN error envelope
(adding a correlation id, switching the message) would require
touching 34 files.

**Problem 2 — `guardSameTenant() / assertSameTenant()` duplicated
in ~20 controllers.** Same pattern, same body:

```php
if ($model->company_id !== auth()->user()->company_id) {
    throw new ApiException('NOT_FOUND', 'Resource not found.', 'المورد غير موجود.', 404);
}
```

Same maintenance trap. UserController used the alias
`assertSameTenant`; everyone else used `guardSameTenant`. Both
are the same function.

**Problem 3 — list-envelope JSON tree hand-rolled in ~10
controllers.** Pattern repeated:

```php
return response()->json([
    'data' => ['items' => $items, 'kpis' => $kpis, 'meta' => [...]]
]);
```

Subtle drift across controllers — some emitted `meta` outside
`data`, some inside; some used `kpis` as a sibling, some as a
nested key. Section 20 Finding 13 + Finding 17 closed the drift
on Products + Categories + Units; Section 22 closes it everywhere.

**Fix — base Controller + ApiResponse::list helper.**

| File | Change |
|---|---|
| `app/Http/Controllers/Controller.php` | Adds `protected function authorizeP($perm)`, `protected function guardSameTenant($model)`, `protected function assertSameTenant($model)` (alias). Same bodies as the per-controller copies; defined once. |
| `app/Support/ApiResponse.php` | Adds `ApiResponse::list($items, $kpis, ?LengthAwarePaginator $rows)` returning the canonical `{data: {items, kpis, meta}}` envelope. One line replaces every `response()->json([...])` boilerplate. |
| 34 controllers | `private function authorizeP` blocks deleted via a single perl one-liner pass. ~170 LoC removed. |
| ~17 controllers | `private function guardSameTenant` / `assertSameTenant` blocks deleted. ~100 LoC removed. |
| `MediaController.php` | Kept its bespoke `guardSameTenant` (genuinely different — superadmin bypass + null `company_id` tolerance). Visibility widened from `private` to `protected` to satisfy the inheritance contract. |
| `BranchController::index`, `UserController::index`, `ProductController::index` | Hand-rolled envelope JSON tree → single-line `ApiResponse::list($items, $kpis, $rows)`. |

### Unification — frontend envelope helper

**Problem — three near-duplicate `getEnvelope` helpers** lived in
`merchandise/catalog.api.js`, `ops/branches.api.js`, and
`ops/rbac.api.js`. Each did the same job: call `realHttp.get`
directly to keep the paginator wrapper that `realRequest` would
otherwise drop. Subtle differences across the three (only one
translated `{data: rows}` → `{items: rows}`, one returned the raw
wrapper verbatim, etc.).

**Fix — single canonical helper at `app/src/core/api/envelope.js`.**

```
export const getListEnvelope = async (url, params, lang) => {
  // Translates BOTH legacy {data: [rows], meta} AND canonical
  // {data: {items, kpis, meta}} → {items, meta, kpis?} for FE consumers.
};
export const getOneEnvelope  = ...;   // single-row read with custom headers
export const patchOneEnvelope = ...;  // PATCH with custom headers
```

Three modules now import from this one file. The translation
layer means the FE shape is stable as the BE migrates more
endpoints to the canonical envelope — pages don't have to change.

### Ops audit fixes

| Finding | Resolution |
|---|---|
| **O-1** Branches/Users/Products list use canonical `{data: {items, kpis, meta}}` envelope | All three now go through `ApiResponse::list`. Two test assertions updated (`meta.total` → `data.meta.total`). |
| **O-2** BranchDetailPage uses real `/branches/{id}` | New `useBranchDetail(id)` hook + `branchesApi.show(id)`. Page used to filter a `per_page=200` list fetch in memory because of a stale "Postman doesn't expose this" comment. The route exists and has since Phase 1. |
| **O-3** Demo pages show the action buttons disabled with an inline note | ApprovalDetailDrawer's Approve/Reject buttons disabled with an amber "demo only — persists nowhere" note. Action becomes live the moment the BE ships and the gate is removed. |
| **O-4** Mock-only ops hooks live in retail.hooks.js | Deferred — these (`useOpsTasks/Approvals/Audit/Branches`) only stay in retail.hooks.js because the BE doesn't exist. When endpoints ship, they move to `@modules/ops/*` to mirror Section 21's pattern. |
| **O-5** branches.api.js getEnvelope translates to canonical `{items, meta}` | Covered by the shared `core/api/envelope.js` migration above. |
| **O-7** `useMyBranches` deduped | Was exported from BOTH `branches.hooks.js` and `rbac.hooks.js` with identical signatures, no consumers. Deleted the rbac.hooks.js copy (and its supporting `rbacApi.meBranches` + `rbacQk.meBranches`). |
| **O-8** `BranchController::store` validator accepts `cloud_kitchen` | One-word change to the `branch_type` enum: `in:retail,restaurant,mixed,cloud_kitchen`. Matches the filter dropdown + the KPI block that already counts cloud_kitchens. |

### Retail page polish (per the user's screenshot)

The Retail page subtitle ("POS, inventory, suppliers, customers,
growth — retail-specific surfaces") was stale after Section 21
promoted Inventory to a top-level shared module. Updated:

```diff
- POS, inventory, suppliers, customers, growth — retail-specific surfaces.
+ POS, suppliers, customers, growth — retail-specific surfaces.
+ Catalog, Inventory and Ops live cross-module.
```

Cross-module tab labels (`Catalog (shared)`, `Inventory (shared)`,
`Ops (shared)`) refactored to a single `sharedLabel(en, ar)`
helper in `RetailPage.jsx`. The literal "(shared)" text is gone;
each link renders the bare label plus a small ↗ glyph. The "this
is a cross-module link" semantic is communicated visually instead
of via repeated parenthetical text. Screen readers get an explicit
`aria-label="shared cross-module link"`.

### Files touched (high level)

```
backend/
  app/Http/Controllers/Controller.php       (base class — primitives)
  app/Support/ApiResponse.php               (+ list() helper)
  app/Http/Controllers/                     (34 controllers — private dupes deleted)
    BranchController.php                    (+ canonical envelope, cloud_kitchen validator)
    UserController.php                      (+ canonical envelope)
    Catalog/ProductController.php           (envelope refactored to ApiResponse::list)
    MediaController.php                     (visibility widened on bespoke guardSameTenant)
  tests/Feature/
    ProductsTest.php                        (data.meta.total assertion)
    ShippedEndpointsHappyPathTest.php       (same)

app/src/
  core/api/envelope.js                      (NEW — single helper for 3 modules)
  modules/merchandise/catalog.api.js        (uses shared helper)
  modules/ops/
    branches.api.js                         (uses shared helper; + show())
    branches.hooks.js                       (+ useBranchDetail; envelope.items)
    rbac.api.js                             (uses shared helper; − meBranches)
    rbac.hooks.js                           (− useMyBranches dedupe; envelope.items)
    BranchesPage.jsx                        (q.data?.data → q.data?.items)
    UsersAccessPage.jsx                     (same)
    BranchDetailPage.jsx                    (useBranchesList → useBranchDetail)
  modules/retail/
    RetailPage.jsx                          (subtitle update + sharedLabel helper)
    _shared/ApprovalDetailDrawer.jsx        (Approve/Reject disabled with demo note)
```

### Verification

| Check | Result |
|---|---|
| Backend `phpunit` | **703 / 704** (same pre-existing HrWiringTest time-of-day flake) |
| Frontend `npm run build` | ✓ clean — 0 errors, 0 warnings |
| `grep "private function authorizeP\\b"` controllers/ | 0 — every duplicate removed |
| `grep "private function guardSameTenant\\b"` controllers/ | 1 — only MediaController's bespoke variant remains |
| `grep "getListEnvelope"` consumers | 3 — merchandise + ops/branches + ops/rbac |
| Endpoint envelope check | `/branches`, `/users`, `/catalog/products` all emit `{data: {items, kpis, meta}}` |

### Architectural rules confirmed (now applied 4 times)

- **Move duplicated business logic to a single source of truth.** 34
  copies of `authorizeP` → 1. 17 copies of `guardSameTenant` → 1
  (plus 1 bespoke). 3 copies of `getEnvelope` → 1. Net minus
  442 lines because deleting duplicates costs nothing.
- **Translate at the boundary, not at the consumer.** The envelope
  helper accepts BOTH legacy and canonical BE shapes; pages bind
  to one stable FE-side contract. As more BE endpoints migrate to
  the canonical envelope, the FE doesn't churn.
- **Same-name siblings are a maintenance trap.** Two `useMyBranches`
  exports across sibling files → deleted the duplicate. Same
  rationale as Section 21's catalog hook consolidation.

### What's deferred (genuine future work)

- **Suppliers / RFQs / POs hook surface** — still in
  `retail.hooks.js` with `['retail']` cache prefix. Promotion to a
  top-level Suppliers module needs a decision about ecom + dine
  consumers first (same gate as Section 21).
- **Demo Ops hooks (Tasks/Approvals/Audit/Branches aggregator)** —
  stay mock-pinned until the merchant-side BE ships. Consolidating
  them into `@modules/ops/*` is a natural follow-up at that point.
- **`shape()` / row-shaper traits across controllers** — every
  controller still has its own `private function shape($row)`.
  Per-domain enough that consolidating into shared traits would be
  premature abstraction; left alone for now.
- **`KpiBuilder` helper** — every list controller hand-rolls its
  KPI block. Patterns are similar but the dimensions are
  domain-specific; another candidate for "premature abstraction"
  unless we hit the 4th or 5th near-duplicate.

---

## 23 · Drop cross-module duplicates from Retail + Dine in-app nav

User flagged the screenshot: the Retail page's tab nav still showed
`Catalog ↗`, `Inventory ↗`, `Ops ↗` cross-links even though Sections
17, 21, and 23 now mount each as a top-level sidebar entry. Same
pattern in Dine. The duplication made the in-app groups look
bloated AND obscured what's actually retail-/dine-specific.

This batch removes the duplicates and rearranges what remains.

### Change 1 — Operations sidebar entry (Business group)

The OpsShell at `/app/ops` was previously orphaned for tenants —
the only sidebar entry was `/app/ops/roles` under Settings (one
deep-link), and the rest of the shell (Today, Branches, Shifts,
Users-access, Tasks, Approvals, Audit) was reachable only via the
Retail page's `Ops ↗` cross-link. Removing that cross-link without
adding a sidebar entry would have lost discoverability for the
whole shell.

`nav.config.jsx` Business group gains:

```js
{ path: '/app/ops', icon: 'shield', label_en: 'Operations',
  label_ar: 'العمليات', permission: 'branches.view' },
```

Distinct from the platform-side `Operations` group (`/app/platform/*`
— health, compliance, incidents, releases, audit) which stays
`platformOnly: true`. Same English label, completely different
route family — the platform sidebar group renders only for staff
who hold platform perms, so tenants never see both at once.

### Change 2 — Retail in-app nav

| Before | After |
|---|---|
| **Point of sale** (4): Sell · Flow · Receipts · Returns | **Point of sale** (4): Sell · Flow · Receipts · Returns |
| **Merchandise** (3): Catalog ↗ · Inventory ↗ · Suppliers | (group dropped — Catalog and Inventory have sidebar entries; Suppliers moved) |
| **Audience** (2): Customers · Growth | **Audience** (2): Customers · Growth |
| **Operations** (2): Ops ↗ · Zero-Friction | (group dropped — Ops has a sidebar entry; ZF moved) |
| | **Back-office** (2): Suppliers · Zero-Friction |

Net: 4 groups → 3 balanced (4-2-2). The new "Back-office" label
avoids colliding with the new top-level `Operations` sidebar entry.
The `sharedLabel(en, ar)` helper introduced in Section 22 is
deleted — no consumers left.

Subtitle updated to match:

```diff
- POS, suppliers, customers, growth — retail-specific surfaces.
- Catalog, Inventory and Ops live cross-module.
+ POS, suppliers, customers, growth, automation — retail-specific surfaces.
```

### Change 3 — Dine in-app nav

Removed:
- **Menu** group's `Catalog (shared)` cross-link (4th item dropped, group keeps 4 dine-specific tabs).
- **Operations** group's `Ops (shared)` cross-link — this group had ONLY the cross-link, so the entire group is gone.

After: 4 groups → 3 (Live ops · Menu · Guests · Channels). Subtitle
already accurate; no copy change needed.

### Files touched

```
app/src/app/nav.config.jsx                  (+ Operations entry in Business)
app/src/modules/retail/RetailPage.jsx       (− 3 cross-links, regrouped 6 tabs into 3 groups, subtitle)
app/src/modules/dine/DinePage.jsx           (− 2 cross-links, dropped empty Operations group)
```

3 files, +25 / −22 LoC. Frontend `npm run build` clean. Backend
suite unchanged (no BE work in this batch) — 703/704 (pre-existing
HrWiringTest flake).

### Architectural rules confirmed

- **No duplicate access paths to the same surface.** Sections 17/
  21/23 promoted Catalog/Inventory/Ops to top-level. Sidebar already
  surfaces them; the in-app cross-links were dead weight.
- **A group with only cross-links isn't a group.** Dine's
  Operations group held a single `Ops ↗` link; once the link goes,
  the group goes too. Same rule applied across the codebase any
  time a refactor empties a section.

### What's still under the Retail page (truly retail-specific)

- **Point of sale** — POS surfaces unique to retail (Sell, Flow,
  Receipts, Returns). Dine has its own server-POS shell.
- **Audience** — Customers + Growth (loyalty, gift cards, bundles,
  layaways, campaigns). The Customers list itself is shared via
  `useCustomers` from retail.hooks.js but consumed nowhere outside
  retail today; promotion deferred until ecom + dine grow consumers.
- **Back-office** — Suppliers (RFQs/POs/marketplace) + Zero-Friction
  (OCR, learned, ZATCA, shelf scans). Both retail-only today.

---

## 24 · Retail-flow defects surfaced by a real-job probe

End-to-end smoke test against the live MySQL backend (Section
"change to mysql on local host") drove a complete retail flow:
login → catalog → open shift → multi-tender sale → inventory
delta → trial-balance check → PDF receipt → partial return →
shift close. The core ledger + inventory math is correct, but
five auxiliary defects surfaced — none corrupt data, but all
five distort what the merchant sees on day 1. The biggest
(Defect 5) silently broke every retail return with non-zero VAT.

This batch closes all five with focused fixes + new tests.

| # | Defect | Severity |
|---:|---|---|
| 1 | `/pos/shifts/active` returned `branch_en: null`, `cashier: null` | LOW |
| 2 | `today_count` / `today_total` dropped to 0 after a refund | HIGH |
| 3 | `refund_rate` was count-based, displayed "100.0%" after 1 partial refund | MEDIUM |
| 4 | `expected_cash` didn't subtract cash refunds → phantom shortages at close-out | HIGH |
| 5 | `refund_payments.amount` semantic mismatch (FE sent gross, BE expected net) | **HIGH** |

### Defect 1 — active shift eager-load

`PosController::activeShift()` issued a bare `PosShift::where(...)
->first()` with no eager-load. The shape helper rendered
`branch_en` / `branch_ar` / `cashier` as `null` because the
relations weren't loaded. PosLauncherPage's operator card showed
`—` / `—` instead of "Head office / Owner User".

**Fix:** add `with(['branch:id,code,name_en,name_ar', 'cashier:id,name'])`
to match what `shiftsIndex` already does. `shapeShift` updated to
carry the bilingual branch + cashier name when the relations are
loaded; falls through to `null` when called from a lean code-path.

### Defect 2 — today_count / today_total drop to 0 after a refund

`computeSalesKpis` filtered `where('status', 'completed')` for
both `today_count` and `today_total`. After ANY partial refund
the sale's `status` flips to `refunded`, dropping it from
today's tally. With 1 sale + 1 partial refund, the dashboard
read 0 sales today even though the cashier really had rung up
a sale.

**Fix:** include `refunded` in the today filter. The dashboard's
question is "what did the cashier ring up today", not "what's
still pristine". A sale's revenue counts on the day it happened
regardless of subsequent refund state — the refund itself shows
up separately on the returns dashboard.

### Defect 3 — refund_rate computed by count

```
refund_rate = (refundedSales / totalSales) * 100
```

With 1 sale + 1 (partial) refund: 1/1 = 100%. The strip read
"100% refund rate" — alarming and wrong. The metric also
ignored value: a 4 SAR refund on a 200 SAR sale registered the
same as a full 200 SAR refund.

**Fix:** switch to value-based.

```
refund_rate = (sum(PosReturn.total) / sum(PosSale.total)) * 100
```

Same example now reads 4.60 / 124.20 ≈ 3.7%. Answers the
merchant's actual question — "how much of my revenue is leaking
back out as refunds?" — and over-weighted single-line refunds
no longer skew it.

### Defect 4 — expected_cash ignores cash refunds

`PosService::createSale` correctly bumped `shift.expected_cash`
UP by the cash leg of every sale. But `PosController::returnSale`
never bumped it DOWN for cash refunds. Every refund showed up
as a phantom shortage at close-out exactly equal to the refund
amount. Over a busy day with 30 refunds the variance reads the
cashier as systematically short by hundreds — a deeply
trust-destroying display bug.

**Fix:** inside the return transaction, decrement
`shift.expected_cash` by the cash portion of `refund_payments`.
Wallet / card / gift_card / store_credit refunds skip this leg
since they don't physically touch the drawer.

```php
$cashRefunded = (float) collect($data['refund_payments'])
    ->where('tender', 'cash')->sum('amount');
if ($cashRefunded > 0 && $sale->shift_id) {
    PosShift::where('id', $sale->shift_id)
        ->update(['expected_cash' => DB::raw('expected_cash - ' . $cashRefunded)]);
}
```

### Defect 5 — refund_payments amount net-vs-gross mismatch (the big one)

The biggest defect of the batch. Pre-24, `PosController::returnSale`
treated `refund_payments[].amount` as **net-of-VAT** (subtotal),
while `PosService::createSale`'s `payments[].amount` was
**gross-of-VAT** (what the customer actually hands over). Two
sister endpoints, opposite semantics.

The FE ReturnsWizardPage computed `refund = totals.total` (gross
— matching what the customer sees on the receipt) and posted
that to the BE. Result: **every retail return with non-zero VAT
silently failed `JOURNAL_UNBALANCED`** with a generic error
message that gave the FE devs no clue which side was wrong.
Tax-free returns happened to balance; demos worked; production
would have been broken from day 1.

This was discovered by the live probe — sending 4.60 (gross)
got rejected; sending 4.00 (subtotal) worked. That was the
trigger for the fix.

**Fix:** controller `total` is now gross.

```php
$netSubtotal = collect($data['lines'])->sum(...);
$vatRatio    = (float) $sale->subtotal > 0
    ? (float) $sale->tax_total / (float) $sale->subtotal
    : 0.0;
$vatShare    = round($vatRatio * $netSubtotal, 2);
$total       = round($netSubtotal + $vatShare, 2);
```

Plus a focused pre-validation: if `sum(refund_payments.amount)`
doesn't match `total` within a halala, throw
`REFUND_AMOUNT_MISMATCH` with both `expected` and `got` in the
error envelope. The FE can render an actionable message instead
of the old `JOURNAL_UNBALANCED`.

`PosReturnPoster` math is unchanged — it always computed the
net/VAT split internally from `$return->total`. Now it gets the
gross value it always wanted.

### Test-fixture migration (4 existing tests + 2 new)

Five tests passed `refund_payments.amount` as the SUBTOTAL —
they worked under the old semantic. Updated to gross:

| Test | Old amount | New amount |
|---|---|---|
| `PosTest::test_partial_return_creates_refund_and_returns_stock` | 50 (net) → assert data.total = 50 | 57.50 (gross) → assert data.total = 57.50 |
| `PosWiringTest::test_returns_index_returns_envelope_with_kpis` | 100 (net) | 115 (gross). Row total assertion 100 → 115. |
| `PosPostingTest::test_partial_return_reverses_proportional_vat...` | 50 (net), DR=70 CR=70 | 57.50 (gross), DR=77.50 CR=77.50; refund leg net 50 / VAT 7.50 |
| `JournalBranchDimensionTest::test_pos_return_journal_lines_carry_branch_id` | 25 (net) | 28.75 (gross) |
| `WiringSmokeE2ETest::pos_partial_return_lifecycle` | 200 (net), value_30d 200 | 230 (gross), value_30d 230 |

The proportional-VAT test (`PosPostingTest`) is especially
good evidence — the same poster math gets the right answer at
either scale, just the totals shift up by the VAT.

Plus 2 new pinning tests in PosWiringTest:

- `test_cash_refund_decrements_shift_expected_cash` — Defect 4
  pin. Sells 100+15 = 115 cash → `expected_cash = 115`. Refunds
  the lot → `expected_cash` drops back to 0.00 (NOT stays at 115
  which would falsely show the cashier 115 short).
- `test_return_rejects_mismatched_refund_amount_with_focused_error`
  — Defect 5 pin. Sends 100 (net) when 115 (gross) is required;
  asserts 422 + `error.code = REFUND_AMOUNT_MISMATCH` +
  `error.fields.refund_payments.{expected, got}` carry both values.

### Live probe — same scenario as the smoke that surfaced the bugs

Against MySQL on `:8889`, fresh seed, owner@acme.test:

```
── Defect 1: active shift includes branch + cashier ──
{ branch_en: "Head office", cashier: "Owner User", ... }   ✓

── Defect 2: today_count + today_total INCLUDE the just-made sale ──
{ today_count: 1, today_total: 124.2, ... }                ✓

── Defect 5a: refund with NET amount (4.00) — should now error LOUDLY ──
{ code: "REFUND_AMOUNT_MISMATCH",
  message_en: "Refund payments must sum to the gross total 4.6 SAR
              (subtotal 4 + VAT 0.6). Got 4.",
  fields: { refund_payments: { expected: 4.6, got: 4 } } }  ✓

── Defect 5b: GROSS amount (4.60) — succeeds ──
{ return_id: "01kr35j1qkdjq7a1vqfs4trgqt", total: 4.6 }     ✓

── Defect 3: refund_rate now value-based (4.60 / 124.20 ≈ 3.7%) ──
── Defect 2: today_count stays 1 even after refund ──
{ today_count: 1, today_total: 124.2, refund_rate: "3.7%" } ✓

── Defect 4: expected_cash = 200 + 124.20 - 4.60 = 319.60 ──
{ opening_cash: 200, expected_cash: 319.6 }                 ✓
```

### Files touched

```
backend/
  app/Http/Controllers/Pos/PosController.php
    activeShift()              + eager-load branch + cashier
    shapeShift()               + bilingual branch_en/ar + cashier surfaces
    returnSale()               total = gross, REFUND_AMOUNT_MISMATCH
                               pre-validation, expected_cash decrement
    computeSalesKpis()         today filter includes refunded;
                               refund_rate switched to value-based
  tests/Feature/
    PosTest.php                + amount 50 → 57.50, total assertion same
    PosWiringTest.php          + amount 100 → 115; row total 100 → 115;
                               + new test_cash_refund_decrements_shift_expected_cash
                               + new test_return_rejects_mismatched_refund_amount_with_focused_error
    PosPostingTest.php         + amount 50 → 57.50, totals 70 → 77.50,
                               leg breakdown updated
    JournalBranchDimensionTest + amount 25 → 28.75
    WiringSmokeE2ETest         + amount 200 → 230, value_30d 200 → 230
```

5 BE files, +120 / -55 LoC. Backend suite **706 / 707 passing**
(2 new tests, only the pre-existing HrWiringTest time-of-day
flake remaining — unchanged across batches).

### Architectural rules confirmed

- **Symmetric semantics across paired endpoints.** Pre-24 the
  sale tender was gross-of-VAT and the refund tender was
  net-of-VAT. That asymmetry is invisible until a non-trivial
  return — exactly what the smoke test surfaced. Sister endpoints
  should share semantics; if they can't, the divergence belongs
  in the response shape (e.g. an explicit `subtotal` + `gross`
  payload), not in opaque expectation.
- **Failed validation should name the field.** A generic
  `JOURNAL_UNBALANCED` told the FE devs nothing. A focused
  `REFUND_AMOUNT_MISMATCH` with `expected` / `got` lets the FE
  render an actionable message OR auto-correct.
- **Phantom shortages destroy operator trust.** The
  `expected_cash` defect was small in code (+5 LoC) but the
  cashier-side experience was "the system says I'm short every
  time I refund anything". Worth fixing the moment it surfaces.

### What's still NOT addressed

This batch was intentionally focused on the 5 surfaced defects.
Adjacent issues worth their own ticket:

- **Variance signage display.** `variance < 0` reads as
  "shortage", `> 0` as "over". For a refund-driven shift that
  gets cash back into the drawer (e.g. customer returns cash to
  exchange for a card-paid item), the math should still
  reconcile but the "shortage" framing might confuse — needs
  product copy review.
- **Multi-tender refunds.** A sale paid mada+cash that gets
  partially refunded might want to refund proportionally to
  each tender (90 of the 100 mada → mada refund, 10 of the 15
  cash → cash refund). Today the FE only sends one tender per
  refund. Out-of-scope here.
- **Carbon-3 timezone test.** Defect 2 fix happens to dodge the
  TZ trap, but the test fixture is still UTC-only. A test that
  pins the today filter against `Asia/Riyadh` would catch any
  future regression that re-introduces the issue across DST or
  midnight boundaries.

---

## 25 · /pos surface FE audit + 3 follow-up fixes

End-to-end audit of the kiosk POS at `/pos` + the PosLauncherPage
launcher at `/app/retail/sell`. The Section 24 BE fixes hold
under live MySQL traffic; the FE side has three shape-drift
bugs that the audit surfaced. Same pattern as Section 24 — none
corrupt data, but each blocks the operator at some point in the
real-world flow.

### Live trace (against MySQL, the exact calls the FE makes)

```
Step 1  PosLauncherPage  /pos/shifts/active   → { id, branch_en, cashier, opening_cash } ✓
Step 2  OpenShiftDialog  POST /pos/shifts/open → 201 ✓
Step 3  PosLauncherPage  /pos/shifts/active   → reflects the new shift ✓
Step 4  PosFullscreen    /pos/shifts          → paginated list, kpis included ✓
Step 5  PosPage          /catalog/products    → real stock + reorder + branches ✓
Step 6  CheckoutModal    POST /pos/sales      → SALE-…, balanced journal, stub PSP ref ✓
```

The flow works. What surfaced:

### P-1 (HIGH) — `PosFullscreen` paginated `/pos/shifts` instead of `/pos/shifts/active`

```js
// pre-25
const shifts = useOpsShifts();
const myShift = (shifts.data?.items || [])
  .find((s) => s.cashier_id === user?.id && s.status === 'open');
```

`useOpsShifts` paginates `/pos/shifts` (per_page=25 by default).
On a busy multi-cashier branch the cashier's own open shift might
not be on page 1, so the kiosk silently flipped to the
`<NoShiftGate />` even with an open shift — making the cashier
think they had to re-open one and triggering `SHIFT_OPEN` 422
every time they tried.

The companion `PosLauncherPage` got this right since Batch 13:
`useActiveShift()` → `/pos/shifts/active` (a single-row read of
the bearer's own open shift, returns null if none). PosFullscreen
now uses the same pattern.

### P-3 (LOW) — hardcoded operator on the Customer-Facing Display

`PosPage`'s `publishCFD()` calls baked in `branch: 'Riyadh —
Olaya'` and `cashier: 'Sara F.'` as constants. The CFD subscriber
displays the wrong operator/branch regardless of who's actually
ringing up the sale — looks fine in a demo, embarrassing in
production. Fix: read both from `useSessionStore` like every
other operator-facing surface.

### P-5 (MEDIUM) — `useOpenShift` / `useCloseShift` didn't invalidate `useActiveShift` cache

Pre-25 the mutation hooks invalidated only the paginated
`['retail','ops','shifts']` query key. The single-row
`['retail','pos','active-shift']` query stayed stale until the
next window-focus refetch. After clicking "Open shift" in the
launcher, the page kept showing the "Open shift" CTA for a few
seconds until React Query happened to refetch. Fix: invalidate
both cache prefixes from a shared `_invalidateShifts(qc)` helper.

### Files touched

```
app/src/modules/retail/pos/PosFullscreen.jsx     (P-1: useActiveShift)
app/src/modules/retail/pos/PosPage.jsx           (P-3: real session data into publishCFD)
app/src/modules/retail/retail.hooks.js           (P-5: invalidate active-shift on open/close)
```

3 files, +25 / −8 LoC. Frontend `npm run build` clean. Backend
suite unchanged at 706 / 707 (no BE files touched in this batch).

### What was solid (no fix needed)

- `OpenShiftDialog` correctly maps the FE form to the BE-canonical
  `branch_id` + `opening_cash` shape (the comment at line 26-36
  already documented an earlier fix where the wrong key was
  silently dropped).
- `CheckoutModal` builds the canonical `/pos/sales` body — split
  tenders aggregate to one row per `(tender, tender_brand)`
  pair, exactly what the PSP-stub seam expects. Section 5 of
  the WORK-LOG covers this contract.
- The auth session mapper (`auth.session.js`) falls through
  `name_en/name_ar` to `name`, so the operator name renders
  correctly even though `/me` returns those fields as null for
  seeded users.
- `PosFullscreen.jsx:137` still references `user?.name_ar` /
  `user?.name_en`. Works because of the mapper above; not a
  defect.

### What's still NOT addressed

- **Multi-tender split refund**. Section 24 said the same. Today
  the FE returns wizard sends one tender per refund. A sale paid
  mada+cash would ideally refund proportionally to each tender;
  out-of-scope here.
- **Manager override step-up**. `ManagerOverride` dialog renders
  a confirmation but doesn't yet hit the BE step-up endpoint. In
  prod we'd want the discount-cap and void-after-tender flows to
  capture a real step-up ticket, not just an OK click.
- **PosLauncherPage's hardcoded "../ops/shifts" Quick Link** —
  Section 23 promoted Operations to top-level; the link points
  at the legacy `/app/retail/ops/shifts` redirect path. Works
  via redirect but a direct `/app/ops/shifts` link is cleaner.

---

## 26 · CheckoutModal — Confirm button silently did nothing (Section 26)

### Problem

After the Section 25 flow audit confirmed the checkout **worked** in
the smoke test, the user reported that clicking **Confirm** in the
checkout modal had no visible effect — the button appeared to do
nothing. Cashier could click it repeatedly; no sale was created, no
error shown.

### Root cause analysis

Three issues stacked to create the silent failure:

**Bug 1 — Missing `catch` block (PRIMARY)**

```js
try {
  const resp = await createSale.mutateAsync(body);
  onDone({ … });
} finally {           // ← only finally, no catch
  setSubmitting(false);
}
```

Any error thrown by `createSale.mutateAsync` — network failure,
`JOURNAL_UNBALANCED`, `SHIFT_CLOSED`, 422 validation — was silently
swallowed. `submitting` flipped back to `false`, the button
re-enabled, and the cashier saw no feedback whatsoever.

The `ApiError` instances thrown by `realRequest` carry `message_en`
(the backend's human-readable bilingual string) plus the error code.
Without a `catch`, all of that was lost.

**Bug 2 — Silent guard on missing shift (SECONDARY)**

```js
if (!shiftId) return;   // ← no message, just returns
```

If `useActiveShift` hadn't yet resolved (loading race on
mount, or shift was closed in another tab), the function returned
silently. The button appeared enabled (because `!activeShift.isLoading`
kept `noActiveShift` false during the query), but nothing happened.

**Bug 3 — Cash drawer indicator left open on error**

When a cash-tender sale failed, `setDrawerOpen(true)` had already
been called before the POST. On error, the drawer-open indicator
stayed green — implying a drawer had opened when the sale hadn't
actually committed.

### Fix — `CheckoutModal.jsx`

```
Added:   confirmError state (cleared on modal open + each new attempt)
Added:   catch (e) block — extracts e.message_en (ApiError) with
         fallback chain → sets confirmError + closes drawer indicator
Changed: if (!shiftId) return → sets confirmError with bilingual msg
Added:   ⚠ rose banner renders confirmError in the tender column
```

Error extraction priority in the catch:
1. `e?.message_en` — `ApiError` direct field (most informative)
2. `e?.response?.data?.error?.message_en` — raw axios envelope fallback
3. `e?.response?.data?.message` — Laravel validation message fallback
4. `e?.message` — generic JS Error message
5. Bilingual fallback string

### Files touched

```
app/src/modules/retail/pos/CheckoutModal.jsx    +30 / −1 LoC
```

Commit `6c08094`. Frontend `npm run build` clean. No BE changes.

### What this enables

Every failure path now shows the cashier a readable reason:

| Failure | Message shown |
|---------|--------------|
| Tenders don't sum to total | `JOURNAL_UNBALANCED: payments total X ≠ sale total Y` |
| Shift closed in another tab | `No open shift found — please open a shift first or reload the page.` |
| Product was deleted mid-cart | Backend 422 message surfaced verbatim |
| Network outage | `Sale could not be completed. Please try again.` |

The banner is positioned in the tender column (right half of the
modal) where the cashier's eye is during the tender flow — just below
the override-approved pill and above the modal footer.

### Still deferred (unchanged from Section 25)

- Multi-tender split refund
- Manager override step-up (real BE step-up ticket)
- `PosLauncherPage` hardcoded ops/shifts Quick Link

---

## 27 · VAT formula fix — JOURNAL_UNBALANCED on every checkout (Section 27)

### Problem

Every POS sale returned `422 JOURNAL_UNBALANCED: Debits (138) must equal credits (151.2)`.

### Root cause

`pos.utils.js → computeTotals()` treated `l.price` (catalog `base_price` = NET, VAT-exclusive) as **GROSS** (VAT-inclusive) using the wrong extraction formula:

```js
// OLD — wrong: extracted VAT from a "gross" that was actually NET
const vat   = +(taxable - taxable / (1 + VAT_RATE)).toFixed(2);
const net   = +(taxable - vat).toFixed(2);
const total = +taxable.toFixed(2);            // = catalog price (~13% too low)
```

Backend `PosService` stores `base_price` as NET and adds `tax_rate%` on top.
FE `total` was `88` when backend computed `101.2` → journal DR 88 ≠ CR 101.2.

### Fix

```js
// NEW — correct: VAT added on top of NET price
const net   = +Math.max(0, itemsTotal - totalDiscount).toFixed(2);
const vat   = +(net * VAT_RATE).toFixed(2);
const total = +(net + vat).toFixed(2);   // GROSS = NET + VAT
```

### Cascading fixes

`ReceiptsPage` and `ReceiptDetailDrawer` were feeding GROSS `r.total` as a fake "price" into `computeTotals()` for the ZATCA QR. After the formula flip this would double-count VAT. Both files now use the real backend fields:
- `ReceiptsPage` → `r.tax_total ?? 0` directly
- `ReceiptDetailDrawer` → `receipt.subtotal` / `receipt.tax_total`

### Files touched

```
app/src/modules/retail/pos/pos.utils.js              +8 / −10 LoC
app/src/modules/retail/pos/ReceiptsPage.jsx          +3 / −4 LoC
app/src/modules/retail/_shared/ReceiptDetailDrawer.jsx  +5 / −4 LoC
```

Commit `07a9b7f`.

---

## 28 · Accounting + Inventory live audit (Section 28)

### Scope

Full read+write audit of `/accounting/*` and `/inventory/*` endpoints
with the live `manager@acme.test` token. All findings fixed in one commit.

### Defects found and fixed

#### 1 — Wrong step-up on `POST /accounting/journals` (BLOCKING)

**Bug:** `routes/api.php` had `->middleware('stepup:accounting.period.close')` on the journal-create route.
The FE (`accounting.api.js → createJE`) sends `stepUpFor: 'accounting.journal.post'`.
These didn't match → every manual journal entry returned **419 STEP_UP_INVALID**.

**Fix:** Changed the middleware to `stepup:accounting.journal.post`.

```php
// before
Route::post('/accounting/journals', ...)->middleware('stepup:accounting.period.close');
// after
Route::post('/accounting/journals', ...)->middleware('stepup:accounting.journal.post');
```

Verified: `POST /accounting/journals` now returns `419 STEP_UP_REQUIRED` (permission: `accounting.journal.post`),
then succeeds with the correct ticket. JE `JE-2026-000015` created.

#### 2 — Balance sheet equity gap (Balance Sheet did not balance)

**Bug:** `shapeBs()` in `AccountingController.php` only read equity accounts
from the trial balance. Since income/expense accounts are not yet "closed" to
retained earnings (that happens at period-end), equity was always **0** for
any open period. The gap equalled net income exactly (1513.60).

**Fix:** Added a synthetic "Current Year Earnings" line to the equity section
using `incomeTotal − expenseTotal` from the trial balance — the industry-standard
presentation (QuickBooks, Xero, SAP all do this for open periods).

```php
$cye = round((float) ($incomeTotal - $expenseTotal), 2);
if ($cye != 0.0) {
    $equity['lines'][] = ['code' => '__cye__', 'name_en' => 'Current Year Earnings', ...];
    $equity['total'] += $cye;
}
```

Verified: `total_assets (1862.2) = total_liab_equity (348.6 + 1513.6 = 1862.2)`. ✓

#### 3 — Missing step-up enforcement on `POST /accounting/bills/{bill}/pay`

**Bug:** The FE sends `stepUpFor: 'accounting.bills.pay'` for bill payments
but the BE route had **no step-up middleware**. The manager-PIN check was
cosmetic (FE only); any authenticated user could pay bills without PIN.

**Fix:** Added `->middleware('stepup:accounting.bills.pay')` to the bill-pay route.

### Write flows tested (all pass)

| Flow | Endpoint | Result |
|------|----------|--------|
| Create manual JE | `POST /accounting/journals` | ✅ JE-2026-000015 created |
| Create AP bill | `POST /accounting/bills` | ✅ Status: open |
| Pay AP bill | `POST /accounting/bills/{id}/pay` | ✅ Status: paid |
| Create AR invoice | `POST /accounting/ar-invoices` | ✅ INV-2026-000001 created |
| Receive AR payment | `POST /accounting/ar-invoices/{id}/receive` | ✅ Status: paid |
| Create period | `POST /accounting/periods` | ✅ Status: open |
| Create bank account | `POST /accounting/banks` | ✅ IBAN accepted |
| Inventory adjustment | `POST /adjustments` | ✅ Status: posted |
| Inventory transfer | `POST /transfers` → `/ship` → `/receive` | ✅ draft→in_transit→received |
| Stocktake | `POST /stocktakes` → `/lines` → `/finalize` → `/post` | ✅ Status: posted |
| Receiving | `POST /receivings` | ✅ Auto-posts, status: posted |

### Accounting integrity (final state)

- **Trial balance:** DR = CR = 10195.40. Balanced. ✓
- **Balance sheet:** Assets = Liab+Equity = 502.20. Balanced. ✓
- **Note:** Inventory GL (1200) shows negative balance (−2525.40) — seeder seeds many POS sales but only one receiving batch; logged for seeder fix separately.

### Files touched

```
backend/routes/api.php                                          +5 / −2
backend/app/Http/Controllers/Accounting/AccountingController.php +18 / 0
```


