# Admin portal — remediation backlog

**Source:** `03_FINDINGS/admin/` (8 area files + hypothesis files + `_ui-parity.md`). **Verdict and context:**
`REPORT_ADMIN.md`. **Baseline:** mockup `cb48c8d` (v0.28.0). **Compiled:** 2026-08-06.

Every item traces to a findings file. Nothing here is new analysis; where an item's severity or
scope was contested during re-verification, that record travels with it.

Per spec §14 this audit measures — it does not propose implementations. "Files to change" lists
the files each finding implicates; the shared-fix notes describe the *shape* of a fix where two or
more findings collapse into one, because that is a scheduling fact, not a design decision.

---

## How to read an item

| Field | Meaning |
|---|---|
| **Baseline** | What the mockup does (spec §3 pinned ref) |
| **Observed** | What the portal does |
| **Cause** | deviation / gap / drift (§4.1) · not-built / built-not-configured / built-not-working (§4.2) |
| **Files** | Files the finding implicates |
| **Verify** | How to confirm a fix — **including its precondition** |
| **[common/]** | The change is in shared `common/` code and also serves the shop portal. Fixing once serves both; the shop phase re-tests rather than re-reports. |

## Preconditions — read before verifying anything financial

Several items will probe as **0.00 on staging as it stands**. A "verification" against a zero
confirms nothing. Each item names the preconditions it needs by code:

| Code | Precondition | Why |
|---|---|---|
| **P-RATES** | Set non-zero marketing and processing rates on Commercial Config (and/or per shop) before probing | Both processing-fee rows in the entire staging ledger read `0% + 0.00 of collected` and charge 0.00. Every fee resolves to zero today, so fee arithmetic cannot be validated in either direction. |
| **P-OFFER** | Create an offer with `rate_discount_pct > 0` (and, for SE-02, a non-zero `sub_discount` with a lock window) and enrol a shop | Both seeded offers carry `rate_discount_pct = 0`. |
| **P-SMSPRICE** | Set `commercial_config.sms_sell_price` (and/or `wa_sell_price`) to a non-zero value | It defaults to `0.0000 NOT NULL` and `configValue()` treats 0 as a value, so overage charges are written at 0.00 SAR. The wrong allowance is stamped regardless of the price. |
| **P-NOTIFCHG** | Generate at least one `sms` / `whatsapp` charge row | Zero such rows exist on staging, so every code path keyed on them is currently unexercised. |
| **P-CRON** | Advance `next_billing_at` into the past and run `php console/yii subscription-billing/charge` manually | The renewal path is a daily cron; it will not fire on demand otherwise. |
| **P-MARKETING** | A `marketing_fee` row must exist to observe | The staging charge ledger holds six rows and contains **no** `marketing_fee`, `sms` or `whatsapp` row at all. |

**Live reproduction case already on staging:** `CHG-0006` (The Beauty of Nails) charges
**1,696.50** = 1,885.00 × 0.90 — the Pro 6-month price with a 10% offer applied by the subscribe
path. That subscription is the ready-made case for SE-02.

---

## Counts

The two dimensions are counted separately and never blended — spec §4.3. Behavioural (Track L)
items are everything from `# S1 — Critical` to `# Outside the area scorecard`; UI parity (Track U)
items are listed in their own section, `# UI parity findings (Track U)`.

| Dimension | Severity | Items |
|---|---|---|
| Behavioural (Track L) | S1 Critical | 7 |
| Behavioural (Track L) | S2 Major | 38 |
| Behavioural (Track L) | S3 Moderate | 33 |
| Behavioural (Track L) | S4 Minor | 9 |
| Behavioural (Track L) | Outside the area scorecard (H1/H2 UI probes + hypothesis outcomes) | 7 |
| Behavioural (Track L) | **subtotal** | **94** |
| UI parity (Track U) | S2 Major | 0 |
| UI parity (Track U) | S3 Moderate | 8 |
| UI parity (Track U) | S4 Minor | 10 |
| UI parity (Track U) | **subtotal** | **18** |
| | **Total** | **112** |

---

# S1 — Critical

Ordered by **remediation leverage**, not by area. Items sharing a root cause are adjacent, with
the shared fix stated once. Three of the six groups close an S2 as a side effect.

---

## Group A — one resolver, closes S1 + S2

> **Shared fix.** `AdminFinanceController::effectiveCarryThreshold()` resolves through a
> `carry_forward_threshold` column that does not exist on the `settings` table, so it always falls
> through to a hardcoded `200.0`. Pointing the resolver at `commercial_config.carry_forward_threshold`
> — the column the admin screen actually writes — and removing the dead `settings` branch closes
> **CF-CC-01 (S1)** and **F-FIT-04 (S2)**. They are the same code path, found independently by two
> area runs. See `REPORT_ADMIN.md` §7.2.

### CF-CC-01 · S1 · commercial-config · Admin-set carry-forward threshold has no reader
**Contract:** C-ADM-034 · **Cause:** deviation · built-not-working

- **Baseline:** `effectiveCarryThreshold(shop, config) = shop.carryForwardThreshold ?? config.carryForwardThreshold`. The global threshold the admin edits is the live fallback for every shop with no per-shop override, and it drives threshold auto-invoicing (C-ADM-029). Mockup `lib/finance.ts:695`; global seed 1000.
- **Observed:** the resolver (`:326-336`) checks the per-shop column, then `backend\models\Settings->carry_forward_threshold`, then a hardcoded `$defaultCarryThreshold = 200.0` (`:47`). `intialize.sql:556-596` creates `settings` with no such column and no migration adds one, so `__isset` is permanently false. `commercial_config.carry_forward_threshold` has two references outside its own model — the config form and a label map. No reader.
- **Worked example:** admin sets 1000; shop has NULL override, 300 SAR issuable fees, 300 SAR held earnings. Baseline: 300 < 1000, balance carries forward, earnings stay withdrawable. Portal: 300 ≥ 200, invoice issued, 300 of held earnings consumed and removed from withdrawable. Every shop's screen shows a credit limit of 200.00.
- **Scope correction (carry this):** the wrong numbers are the **credit limit and the timing of invoicing**. The invoice **amount** is computed correctly. Do not read this as a mis-stated invoice total.
- **Files:** `backend/controllers/AdminFinanceController.php`, `backend/models/Settings.php`, `common/models/CommercialConfig.php`, `backend/views/commercial-config/index.php`
- **Verify:** no financial precondition needed — this is a schema/resolver check. `SHOW COLUMNS FROM settings LIKE 'carry%'` (expect empty) and `SELECT carry_forward_threshold FROM commercial_config`. After the fix: set the global threshold to a distinctive value, open Shop Balances, and confirm the credit-limit column reflects it for a shop with a NULL per-shop override — and that a shop between the old 200 and the new value no longer auto-issues.
- **Withdraw condition:** a non-empty `SHOW COLUMNS` result downgrades this to a source-of-truth split (the form writes one column, the engine reads another) rather than a fully inert field.

### F-FIT-04 · S2 · finance-invoices-transfers · Global carry-forward threshold not read for threshold auto-invoicing
**Contract:** C-ADM-029 · **Cause:** deviation · built-not-working
Same resolver, same missing column, recorded independently by the finance-invoices-transfers run
and graded S2 there. Closed by Group A's fix. Files: `backend/controllers/AdminFinanceController.php`,
`common/migrations/db/m260628_210000_add_commercial_config_table.php`, `backend/models/base/Settings.php`.
**Verify:** as CF-CC-01.

---

## Group B — one extraction, closes two S1s **[common/ candidate]**

> **Shared fix.** SE-02 and SE-03 originate in one structural decision: the console tier
> hand-mirrors `NavagooPlansController`'s private billing methods rather than sharing them. The
> net-price lookup was not carried across into the mirror; the dev simulation rail was. **Extracting
> a shared billing service used by both the subscribe path and the renewal cron closes both findings
> and removes the mechanism by which the two rails can diverge again.** Fixing each symptom in place
> leaves the duplication that produced them. The extracted service would live in `common/`, so this
> becomes shared code serving both portals.

### SE-02 · S1 · subscriptions-entitlements · Renewal charges plan gross, ignoring the enrolled offer discount
**Contract:** C-ADM-067 · **Cause:** deviation · built-not-working

- **Baseline:** at renewal the charge is raised on the net price — the enrolled offer's `subDiscount` applies while `now <= lockedUntil` (C-ADM-064) — and `currentTermPrice` is re-stamped to that net. Mockup `store.ts:2162` renews on `.net`; `entitlements.ts:298-307` applies `subDiscount` inside the lock window.
- **Observed:** `actionCharge()` line 203 sets `$amount = $plan->priceForPeriod($period)` and passes it to `chargeSubscription()` and `stampTerm()`. The console tier contains **zero** references to `ShopOfferEnrollment` or `netFor`; `current_term_price` is not consulted. The subscribe path (`NavagooPlansController.php:310-312`) does apply `netFor()`, so the discount holds on charge one and is lost from charge two onward. The cron is scheduled daily on **both** qc and prod (`console/config/schedule.php:42,53`).
- **Worked example:** 20%-off offer, 12-month lock, Growth at 500 SAR/month. Subscribe charges **400**, stamps `current_term_price = 400`. Next cron writes **500** and re-stamps **500**. Overcharge **100 SAR/month for the 11 remaining locked months**. The inflated stamp also shrinks any later upgrade proration, which reads `current_term_price` (`NavagooPlansController.php:486`).
- **Files:** `console/controllers/SubscriptionBillingController.php`, `frontend/controllers/NavagooPlansController.php`, `common/models/NavagooOffer.php`
- **Verify — preconditions P-OFFER, P-CRON.** Enrol a shop in an offer with a percent sub-discount and a 12-month lock, subscribe monthly (first charge should be discounted), then advance `next_billing_at` past now and run `php console/yii subscription-billing/charge`. Compare the second charge's `total_amount` with the first, and read `current_term_price`. Confirmed if the renewal equals the plan's gross period price and the stamp is overwritten with the gross.
- **Shortcut:** the staging subscription behind `CHG-0006` (1,696.50 = 1,885.00 × 0.90) is already in the right state; the next cron charge for it is predicted to be the full **1,885.00**.

### SE-03 · S1 · subscriptions-entitlements · Card-rail subscription with no card on file renews as paid
**Contract:** C-ADM-068 · **Cause:** deviation · built-not-working

- **Baseline:** a term counts as paid only when `paymentMethod === 'card'` **and** a `cardLast4` is on file; otherwise the charge is unpaid and the subscription goes `past_due` with `pastDueSince` set. Mockup `store.ts:2039`.
- **Observed:** `chargeSubscription()` (`:467-492`) computes `$useRealRail = $card && PaymobSubscriptionHelper::isConfigured() && token present`. When false — including "the shop has no saved card at all" — control falls through the documented dev fallback to `writeSubscriptionCharge()` with `Charge::STATUS_PAID`; `actionCharge()` then stamps a new term, sets status active, clears `past_due_since` and emails a renewal confirmation. **The fallback is not guarded by any dev/prod flag**: with Paymob fully configured, a shop with no `user_card` row still falls through, because `$useRealRail` requires `$card` to be non-null. Nothing upstream requires a card — `actionSubscribe` never rejects a card-rail subscribe from a user with no card, and `recordSubscriptionCharge` carries the identical fallback.
- **Worked example:** Growth at 500 SAR, no card. Each cycle writes `type = subscription, total_amount = 500, status = paid, settlement_method = charge_to_card` with no `meta.transaction_id`, sets the subscription active, clears `past_due_since`, emails a renewal confirmation. **Zero collected; ledger revenue overstated by 500 per shop per term.** The shop is never taken `past_due`.
- **Files:** `console/controllers/SubscriptionBillingController.php`
- **Verify — preconditions P-CRON, plus Paymob credentials configured on staging.** Take a shop whose subscription `payment_method` is `card`, delete/absent its `UserCard` rows, set `next_billing_at` to a past timestamp, run `php console/yii subscription-billing/charge`. Confirmed if a `Charge` with `status = paid` and `settlement_method = charge_to_card` is written, the subscription flips to active with a new `current_term_end`, and a renewal-confirmation email is sent — with no gateway transaction id in `charge.meta`. After the fix the expected result is an unpaid charge and `past_due` with `past_due_since` set.
- **Related, not closed by this fix:** SE-04 (S2) — nothing subsequently retries or expires a `past_due` subscription, so fixing SE-03 alone moves shops into a state the engine currently never leaves. Schedule SE-04 with it.

---

## Group C — one call site, closes S1 + S2 **[common/]**

> **Shared fix.** No completion or no-show transition invokes the charge-ledger derivation. The
> completion chokepoint `BookingCompletionService::ensureEarnings()` calls `ensureProcessingFee()`
> only. Invoking the ledger derivation from that chokepoint — with a promote-pending-to-unpaid step
> — closes **FIN-LEDGER-01 (S1)** and **FIN-LEDGER-02 (S2)**.
>
> **Do not point remediation at the payout rail.** A parallel legacy rail exists:
> `Earnings::calculateFinancialFields()` writes `navagoo_marketing_fees`, populated by
> `BookingHelper::createBookingEarnings()` at completion, and that is the figure
> `WithdrawalBundlingService.php:236` / `:332` actually nets. Real cash payout may still deduct a
> marketing fee. What is established as wrong is the **charge-ledger-driven figures shown to shops
> and admins**. The payout rail is not broken.

### FIN-LEDGER-01 · S1 · finance-ledger · No completion transition raises a marketing fee **[common/]**
**Contract:** C-ADM-013 · **Cause:** gap · built-not-configured

- **Baseline:** a navagoo-sourced booking raises a marketing fee when it reaches a terminal state; a completed pay-on-visit booking raises it even though nothing was collected on the rail (C-ADM-013, C-ADM-015). Mockup `store.ts:1112` calls `rederiveCharges` on **every** status transition.
- **Observed:** the marketing-fee builder is complete and matches the baseline formula, but `deriveBookingCharges()` is called from exactly six sites — `GroupBookingService.php:390`, `:634`, `:717`, `:783`, `BookingController.php:780`, `UserController.php:283`, `DemoController.php:228` — none of which is a completion path. `BookingCompletionService::ensureEarnings()` calls `ensureProcessingFee()` only (`:75-79`).
- **Worked example:** 500.00 booking, marketing 5%, VAT 15%. Expected row: basis **434.78**, fee **21.74**, VAT **3.26**, total **25.00**. Actual: no row. `netPayout` shows **483.04** instead of **458.04**; `costsToDate()` understated by **25.00 per booking**.
- **Already corroborated live:** the whole staging ledger is six rows with **no `marketing_fee` row**; two completed bookings (91.00 and 136.00) each produced a processing-fee row only; marketing reads 0.00 across all eight transfer requests, including `NTR-QC-260200026` (earned 2,297.01) and `NTR-251200021` (earned 4,516.00).
- **Files:** `common/services/BookingCompletionService.php`, `common/components/FinanceLedgerService.php`
- **Verify — precondition P-RATES.** With a non-zero marketing rate configured, take a navagoo-sourced solo booking from scheduled to completed via the shop portal, then query `charge` for that `booking_id`. Before the fix: processing-fee row only. After: a `marketing_fee` row with the basis/fee/VAT split above. **Without P-RATES the row would be written at 0.00 and the fix would look like a no-op.**

### FIN-LEDGER-02 · S2 · finance-ledger · A pending marketing row is never promoted to unpaid **[common/]**
**Contract:** C-ADM-014 · **Cause:** deviation · built-not-working

- **Baseline:** a marketing row is pending while the booking is non-terminal and becomes real — countable in settlement, balance, invoice and P&L aggregates — when the booking reaches a terminal state.
- **Observed:** pending is modelled as `status = STATUS_PENDING` and is correctly excluded from every aggregate, but no path promotes an existing pending row to unpaid. `deriveBookingCharges()` always constructs a new `Charge` (no exists-guard on the marketing fee, unlike the processing fee at `FinanceLedgerService.php:331-339`), and completion does not call it at all. A group-booking marketing row stamped PENDING at creation stays PENDING after the booking completes.
- **Files:** `common/components/FinanceLedgerService.php`, `common/components/GroupBookingService.php`
- **Verify — precondition P-RATES.** Complete a group-booking child whose creation-time marketing row is PENDING, then re-read that row's status. Confirmed if still PENDING. Note the exists-guard: after the fix, check the completion does not *also* append a duplicate marketing row.

---

## Group D — resolver signature **[common/]**

### NOTIF-02 · S1 · notifications · A plan's bundled message allowance is stored but not read at billing time **[common/]**
**Contract:** C-ADM-060 · **Cause:** deviation · built-not-working

- **Baseline:** `notifFreeLimit(config, plan, channel) = plan.smsIncluded ?? config.smsFreeMonthly` (and `plan.waIncluded ?? config.waFreeMonthly`) — the subscribed plan's bundled allowance wins, the global is only a fallback, and the same resolver serves both send engines and the shop-facing pre-send estimate. Mockup `entitlements.ts:160-165`, used at billing time by `lib/notifications.ts:143`.
- **Observed:** `NotificationDispatchService::freeLimitFor(string $channel)` (`:467-472`) takes no shop or plan argument; it resolves `CommercialConfig.sms_free_month` / `wa_free_month`, else the `ShopNotificationSetting` constants (**50 SMS / 20 WhatsApp**). `navagoo_subscription_plan.sms_included` / `wa_included` exist, are admin-editable, and are documented "NULL = global default" — nothing reads them at billing time. The billing path is real and gateway-independent: `billPaidSend` `:751-755` compares usage against `freeLimitFor()` and `:759-783` constructs and **saves** a `Charge`, called from `dispatchTrigger` `:132` inside the send transaction. The plan link is trivially resolvable — `SettingsController.php:411` loads the plan from `ShopSubscription::plan_id` in the very method that re-derives the allowance without it.
- **Worked example:** Pro shop, `sms_included = 500`, `sms_sell_price = 0.25`, 200 SMS in a month. Baseline **0 charges**. Portal **150 rows × 0.25 = 37.50 plus VAT**, billed inside an allowance the shop already bought. The Settings tile also shows "50 free" to a shop on a 500-SMS plan.
- **Second half of the same fix:** the single-gate property does not hold. The shop-facing figure is re-derived at `frontend/controllers/SettingsController.php:402-403` and again at `frontend/views/settings/_notifications.php:44-45` rather than calling `freeLimitFor()`. The three sites agree today only because all three ignore the plan — so fixing the resolver alone would make them disagree.
- **Files:** `common/components/NotificationDispatchService.php`, `common/models/NavagooSubscriptionPlan.php`, `frontend/controllers/SettingsController.php`, `frontend/views/settings/_notifications.php`
- **Verify — preconditions P-SMSPRICE, P-NOTIFCHG.** Set `sms_included = 500` on a plan, put a shop on it, opt it into SMS on an optional approved trigger, and drive 51 customer sends in one calendar month (or seed the notification rows and run one more dispatch). Confirmed if a charge row appears on send 51 rather than send 501. Also check the shop Settings ▸ Notifications usage tile reads "50 free" and not 500.
- **Dormancy — do not mis-read a zero.** `sms_sell_price` defaults to `0.0000 NOT NULL` and `configValue()` treats 0 as a value, so the charges are 0.00 SAR until a sell price is set. **The wrong allowance is stamped regardless of the price** — the allowance figure, not the amount, is what proves the finding.

---

## Group E — wire an existing, tested service into the fee engine **[common/]**

### CF-CC-02 · S1 · commercial-config · Fee-discount offers are never applied to a charge **[common/]**
**Contract:** C-ADM-036 · **Cause:** gap · built-not-configured

- **Baseline:** an offer's `rateDiscountPct` reduces the stamped rate on the charge types it lists (`marketing_fee` / `payment_processing_fee`) for any charge whose economic date falls inside `[enrolledAt, lockedUntil]`. Mockup `finance.ts:162-163`, gated by `lib/offers.ts:34-40`.
- **Observed:** the arithmetic exists and is unit-tested — `OfferPricingService::rateDiscountPct()` (`:95-108`) — with no caller. Repo-wide, its only references are its own definition, `OfferPricingServiceTest`, and a comment in `EntitlementService`. A case-insensitive grep for `offer|discount` across `FinanceLedgerService.php` returns zero hits; `buildMarketingFee()` stamps `marketingRatePct($shop)` and `buildProcessingFee()` stamps `processingRatePct($shop)` / `processingFixed($shop)` with no discount term.
- **Worked example:** basis 1000 SAR, marketing 10%, offer 25% on `marketing_fee`. Baseline effective rate 7.5% → **75.00**. Portal 10% → **100.00**. **25.00 overcharge per booking** for the lock-in duration.
- **Files:** `common/components/FinanceLedgerService.php`, `common/components/OfferPricingService.php`, `common/models/NavagooOffer.php`, `common/models/ShopOfferEnrollment.php`
- **Verify — preconditions P-OFFER, P-RATES, P-MARKETING (and therefore Group C's fix, or a cancel path, to produce a marketing row at all).** Enrol a shop into an active offer with `rate_discount_pct > 0` and `applies_to_charge_types` containing `marketing_fee`, create and complete a navagoo-sourced booking, then read the `charge` row's `rate_snapshot`. Before the fix: the shop's undiscounted rate. After: the discounted rate. Doable entirely via console/SQL.
- **Dormancy — do not mis-read a zero.** Both seeded offers carry `rate_discount_pct = 0`, so a probe run today shows no discrepancy whether or not the code is fixed. The defect activates the moment an admin creates a fee-discount offer — an ordinary admin action. The shop-facing UI already promises the behaviour (`frontend/views/navagoo-plans/index.php:382`, "{pct}% off fees"). Confidence is high on the code path; **the S1 rating assumes such an offer is created.**

---

## Group F — the missing bidirectional link **[common/]**

### F-FIT-01+05 · S1 · finance-invoices-transfers · No bidirectional link between the settlement rail and the fee rail **[common/]**
**Contract:** C-ADM-024 · **Cause:** deviation · built-not-working
*Replaces the separately-reported F-FIT-01 and F-FIT-05; both ids retained for traceability.
Reported separately they double-count one exposure.*

- **Baseline:** on creation the transfer request writes `transferRequestId` onto **every** included charge row so it cannot be re-batched, and `costsToDate` / invoice builders exclude anything carrying a transfer id. Eligible bookings are those not already consumed by a prior transfer **and** not already netted into an issued invoice's `appliedBookingIds`. Mockup `store.ts:1258-1268`, `store.ts:1179-1197`.
- **Observed — direction A (transfer → invoice):** `WithdrawalBundlingService::linkEarnings()` (`:375-386`) tags only `TYPE_SMS` / `TYPE_WHATSAPP` rows. Marketing-fee and payment-processing-fee rows are never tagged.
- **Observed — direction B (invoice → transfer):** `eligibleEarnings()` filters on `shop_earning.settlement_status` and `withdrawal_id IS NULL` only. `issueShopInvoice()` writes `applied_booking_ids` (line 601) and touches nothing on `shop_earning`; `actionRequestTransfer()` (`EarningsController.php:834-846`) gates only on the minimum withdrawal amount and never checks `consumedBookingIds`.
- **Worked example:** an admin opens Shop Balances, which auto-runs `reconcileBilling()` (`AdminFinanceController.php:116-120`). A threshold invoice nets **1,000 SAR** of held earnings against **98.90** of fees, marks the invoice and its charges paid, and records `applied_booking_ids`. The shop then clicks Request transfer; the `shop_earning` row is untouched, so it is bundled and paid out in full. **98.90 is recorded as recovered from money that is simultaneously paid out.**
- **Correction to the original F-FIT-01 text (carry this):** its stated causal chain was partly wrong. A string coerced to `0` is **not** NULL, so `notInTransfer()` correctly excludes those rows and `consumedBookingIds()` does recognise a booking carrying an SMS/WhatsApp row. **The defect bites bookings with no notification charge — the common case.**
- **Files:** `common/components/WithdrawalBundlingService.php`, `common/models/query/ChargeQuery.php`, `common/migrations/db/m260623_211000_create_charge_ledger.php`, `common/components/FinanceLedgerService.php`, `backend/controllers/AdminFinanceController.php`
- **Verify — preconditions P-RATES, P-MARKETING.** Direction B is the observable half today: issue a threshold invoice that nets held earnings for a shop, record `invoice.applied_booking_ids` and the affected `shop_earning` rows, then request a transfer for the same shop and check whether those earnings are bundled again. Direction A additionally needs **P-NOTIFCHG** plus fee rows of type marketing/processing to exist at all. **With rates at zero the leak is real but its amount is 0.00, so measure the row set, not the money.**
- **Carries OQ-FIT-A — see the open-questions section below. Do not close this item without resolving the column type.**

---

# S2 — Major

38 items. Grouped by area, areas ordered as in the report scorecard. Two S2s (F-FIT-04,
FIN-LEDGER-02) are closed by an S1 group above and appear here as pointers only.

## Subscriptions & entitlements (8)

### SE-01 · Feature access is not snapshotted at the term boundary **[common/]**
**C-ADM-003 · gap · not-built**
- **Baseline:** feature access reads `Subscription.termFeatures` — a snapshot stamped at activation and at each renewal — so editing a plan's `enabledFeatures` does not change a current subscriber's access until its next term boundary.
- **Observed:** `shop_subscription` carries no `term_features` column (`ShopSubscription.php:21-45`) and nothing stamps one. `EntitlementService::shopFeatures()` resolves `$sub->plan->features` on every call. Ticking a box in the admin feature matrix (`ShopController::actionSavePlanFeatures`) rewrites the plan JSON and changes what every mid-term subscriber on that plan can open on the next request. The price side of the stamp does exist (`current_term_price` via `stampTerm()`), so the divergence is feature-specific.
- **Files:** `common/models/ShopSubscription.php`, `common/components/EntitlementService.php`, `backend/controllers/ShopController.php`
- **Verify — precondition: the feature matrix must be populated (see H5.2; it is currently 0 of 57).** Note a mid-term active subscriber's accessible gated surfaces (e.g. `/promo-code`, `/social-media`), untick that feature for its plan in backend Shops ▸ Subscription Plans ▸ Feature matrix, then reload the shop portal **without rolling the term**. Confirmed if the surface renders the locked upsell page immediately — access changed inside a paid term.

### SE-04 · `past_due` and `flagged` subscriptions are never retried, renewed or expired
**C-ADM-067 · deviation · built-not-configured**
- **Baseline:** dunning is checked before renewal — `past_due` with `pastDueSince` and `now >= pastDueSince + subscriptionGraceDays` becomes expired and locks, unless the plan sets `autoDeactivateOnExpiry` false. Renewal covers active and still-in-grace `past_due`.
- **Observed:** `actionCharge()` selects only `status IN (free_period, active)`, and `actionLapse()` explicitly skips `past_due` and `flagged` ("failed card payments never auto-lapse the shop"). Because `subscriptionAccessState()` maps both to `grace` (non-locked), the shop keeps full plan features for an unbounded period with no further charge attempt. No grace-days configuration is read anywhere in the pass.
- **Files:** `console/controllers/SubscriptionBillingController.php`, `common/components/EntitlementService.php`
- **Verify — precondition P-CRON.** Put a subscription in `past_due` with `past_due_since` well in the past, run the billing pass, and confirm the row is untouched. **Schedule with SE-03** — fixing SE-03 alone moves shops into a state this pass never leaves. Corroborating context already on staging: The Beauty of Nails carries three unpaid subscription charges (202.50, 202.50, 314.10) dated 21 Jul 2026 alongside a paid one dated 3 Aug.

### SE-05 · Shops with no subscription bypass all feature gating **[common/]**
**C-ADM-002 · deviation · built-not-configured**
- **Baseline:** `shopFeatures(shop)` returns an empty feature list for a shop with no access-granting subscription — there is no starter/default fallback.
- **Observed:** `EntitlementService::shopCanAccess()` (line 197) takes `$defaultAllow = true` and returns true immediately when `ShopSubscription::findCurrentForShop()` yields null, **documented as a deliberate live-rollout posture** so shops predating billing are not locked out of features already in use. Every `FEATURE_MAP` surface in `EntitlementFilter` is therefore open to a shop that has never subscribed; only the narrower `GO_LIVE_MAP` (booking-calendar, walk-in create) blocks such a shop.
- **Note:** the enforcement path is built and switched on — this is the default posture, a one-line flip, not a rebuild. **Product decision, not purely engineering.**
- **Files:** `common/components/EntitlementService.php`, `frontend/components/EntitlementFilter.php`
- **Verify:** no financial precondition. Sign in as a shop with no `shop_subscription` row and open a `FEATURE_MAP` surface; confirmed open today, expected locked after a deliberate flip.

### SE-06 · Payment-method availability ignores the plan's gating feature **[common/]**
**C-ADM-007 · gap · not-built**
- **Baseline:** `paymentMethodEnabled` = stored per-shop toggle **AND** the plan's gating feature (online → `mada_apple_pay`; deposit and on_visit → `deposits_pos`). A stale toggle left on must not surface a method the plan does not include.
- **Observed:** no resolver combines the two. A repo-wide search for `mada_apple_pay` and `deposits_pos` outside `EntitlementService`'s constants, the unit test and the admin matrix labels returns no hits, and `ShopPaymentSettings` has no plan awareness. Availability is decided by `pay_online_enabled` / `pay_deposit_enabled` / `pay_on_visit_enabled` alone, so a STARTER shop with the toggles on still offers deposits and pay-on-visit in both the booking and walk-in paths.
- **Files:** `common/models/ShopPaymentSettings.php`, `common/components/EntitlementService.php`
- **Verify:** no financial precondition. On a STARTER-tier shop, enable `pay_deposit_enabled` and open the booking/walk-in payment picker. Confirmed if deposit and pay-on-visit are offered despite the plan not including `deposits_pos`.

### SE-08 · No plan-cap lock on specialists at renewal
**C-ADM-010 · gap · not-built**
- **Baseline:** at renewal, if the renewing plan sets `specialistMax`, active specialists beyond the ceiling are set inactive with `planCapLocked` and `wageFrozenAt`; the lock clears only on an upgrade to a plan with room.
- **Observed:** no plan-cap lock exists (no `plan_cap_locked` column or property anywhere in the repo). The renewal branch of `actionCharge()` applies `pending_plan_id`, stamps the term and charges, and never touches specialists. The shop-facing downgrade warning (`navagoo-plans/index.php:270`) states the surplus specialists "will be deactivated" when the downgrade takes effect, **so the UI documents behaviour the engine does not perform.**
- **Files:** `console/controllers/SubscriptionBillingController.php`, `frontend/views/navagoo-plans/index.php`, `common/components/EntitlementService.php`
- **Verify — precondition P-CRON.** Put a shop with more active specialists than the target plan's `specialistMax` on a pending downgrade, run the billing pass, re-read the specialist rows. Confirmed if all remain active.

### SE-09 · Bank-rail upgrade switches the plan before the proration is paid
**C-ADM-069 · deviation · built-not-working**
- **Baseline:** on the bank rail an upgrade issues a subscription invoice for the proration and the plan switch is deferred until that invoice is verified or paid; only a card-paid or exactly-zero proration switches immediately.
- **Observed:** `actionUpgrade()` writes `plan_id`, `payment_method` and `current_term_price` and saves the subscription **before** attempting the proration charge (`NavagooPlansController.php:489-495`), with the comment "the plan switches immediately regardless of the proration charge outcome". On the bank rail `recordSubscriptionCharge()` appends an UNPAID `net_from_settlement` charge and no Invoice is raised, so there is no `pending_verification` document for an admin to act on and no deferral. The higher tier's features are live from the moment of the POST.
- **Files:** `frontend/controllers/NavagooPlansController.php`
- **Verify:** no financial precondition beyond a plan price delta. Upgrade a bank-rail subscription and immediately read `shop_subscription.plan_id` and the `invoice` table. Confirmed if the plan has switched and no subscription invoice exists.

### SE-10 · Paying a subscription invoice by card leaves its charges unpaid and the subscription past_due
**C-ADM-072 · gap · built-not-working**
- **Baseline:** `payInvoice(id, 'card')` sets the invoice paid, flips every charge carrying that `invoiceId` to paid, and reactivates or upgrades the linked subscription.
- **Observed:** `EarningsController::actionPayInvoice`'s card branch (lines 795-806) sets `status` / `paid_at` / `document_status` on the Invoice and saves — it does not update the linked `Charge` rows and contains no `ShopSubscription` lookup. Paying a `trigger = 'subscription'` invoice by card therefore leaves the charge UNPAID and the subscription in `past_due`. The bank branch is unaffected because `AdminInvoiceController::actionVerify` performs both effects.
- **Files:** `frontend/controllers/EarningsController.php`, `backend/controllers/AdminInvoiceController.php`
- **Verify:** no rate precondition — subscription charges carry real amounts on staging. Leave a shop `past_due` with an open `trigger = 'subscription'` invoice, pay it from the shop portal Finance ▸ Invoices by card, then inspect the invoice, its linked charge rows and `shop_subscription.status`. Confirmed if the invoice reads paid while its charges remain unpaid and the subscription stays `past_due`.
- **Related:** F-FIT-03 is the same omission on the fee-invoice side of the same method. One fix likely covers both branches.

### SE-11 · Admin subscription actions never stamp the term record
**C-ADM-065 · deviation · built-not-working**
- **Baseline:** `currentTermStart` / `currentTermEnd` / `currentTermPrice` are the stamped term record; `currentTermEnd` is the single access and renewal boundary.
- **Observed:** backend `ShopController::actionSubscription` (lines 2156-2211) writes only `status`, `period` and `next_billing_at` for assign/activate/cancel/flag and never stamps `current_term_start/end/price`. A subscription created or activated from the admin dashboard therefore has `current_term_end` NULL, which makes `subscriptionAccessState()` return `locked` the instant it is cancelled (rather than running to term end) and makes `actionUpgrade()`'s proration evaluate to 0 because the `end > start > 0` guard fails. `assign` also ignores `trial_consumed` and `free_period_ends_at`.
- **Files:** `backend/controllers/ShopController.php`, `common/models/ShopSubscription.php`, `common/components/EntitlementService.php`, `frontend/controllers/NavagooPlansController.php`
- **Verify:** no financial precondition. Assign a subscription from the admin dashboard, read `current_term_start/end/price` (expect NULL), then cancel it and confirm access locks immediately rather than at term end.

## Commercial config (3)

### CF-CC-05 · Global rates act as a live fallback rather than being copied at shop creation **[common/]**
**C-ADM-034 · deviation · n/a**
- **Baseline:** per-shop commercial rates are copied from the global config at shop creation and read from the shop thereafter; changing the global rate does not retroactively change an existing shop's rate. Only `carryForwardThreshold` and `maxDepositPct` are true nullable overrides with a live global fallback.
- **Observed:** no creation-time copy exists — `ShopController`'s create path makes no reference to `CommercialConfig`, and `common/models/base/Shop.php` seeds `minimum_withdrawal_amount` from a hardcoded model default (`:273`) rather than `commercial_config.min_withdrawal_sar`. Every rate accessor in `FinanceLedgerService` (`marketingRatePct :170`, `processingRatePct :212`, `processingFixed :232`, `minWithdrawal :284`) treats the global as a live fallback for a NULL shop column. **Editing the global rate changes the rate stamped on subsequent charges for every shop without an explicit negotiated rate** — the retroactivity the baseline forbids.
- **Files:** `common/components/FinanceLedgerService.php`, `common/models/base/Shop.php`, `backend/controllers/ShopController.php`
- **Verify — precondition P-RATES.** Create a shop, note its rate columns are NULL, change the global marketing rate, then create and complete a booking and read `charge.rate_snapshot`. Confirmed if the snapshot follows the new global. **Note this interacts with CF-CC-01/CF-CC-02: with rates at 0 the snapshot is 0 either way.**

### CF-CC-06 · `max_deposit_pct` on the config screen is not the enforced cap
**C-ADM-033 · deviation · built-not-working**
- **Baseline:** `maxDepositPct` is part of the global commercial rate card, admin-editable from the config screen, and is the cap a shop inherits when it sets no override.
- **Observed:** `commercial_config.max_deposit_pct` renders and saves (`index.php:115`) but is read only by `FinanceLedgerService::maxDepositPct()` (`:269-276`), which has **no production call site** — its only references outside its own definition are in its unit test. The enforced cap is `ShopPaymentSettings::getEffectiveMaxDeposit()` (`:154-175`): `shop.max_deposit_percent_override`, else `settings.max_deposit_percent`, else the 100 hard cap. An admin lowering Max deposit % sees the value persist while the enforced cap is unchanged.
- **Files:** `backend/views/commercial-config/index.php`, `common/components/FinanceLedgerService.php`, `common/models/ShopPaymentSettings.php`
- **Verify:** no financial precondition. Lower Max deposit % on Commercial Config, then attempt a deposit above the new cap on a shop with no override. Confirmed if the deposit is accepted.
- **Same shape as CF-CC-01, CF-CC-07, F-ADJ-04, F-ADJ-05 — see `REPORT_ADMIN.md` §7.1.**

### CF-CC-07 · `settlement_hold_days` is displayed to shops but does not gate settlement **[common/]**
**C-ADM-033 · deviation · built-not-working**
- **Baseline:** `settlementHoldDays` is part of the global commercial rate card and is the hold a shop inherits (copied at creation per C-ADM-034); the hold gates settlement eligibility.
- **Observed:** `commercial_config.settlement_hold_days` renders and saves (`index.php:140`) and is read **for display** on the shop portal's commercials tab (`SettingsController.php:421-422`), but no eligibility path reads it. The enforced hold is `shop.minimum_elapsed_period_days` with a hardcoded fallback of 7 (`WithdrawalBundlingService.php:49-50`; `EarningsController.php:186-187`; `AgentsWalletController.php:279`), never seeded from the config value. **The number a shop is shown as its settlement hold and the number that actually gates its payouts can disagree.**
- **Files:** `backend/views/commercial-config/index.php`, `common/components/WithdrawalBundlingService.php`, `frontend/controllers/SettingsController.php`, `frontend/controllers/EarningsController.php`
- **Verify:** no financial precondition. Set the config hold to a distinctive value, then compare the shop portal's displayed hold against the age at which an earning actually becomes eligible.

## Finance — invoices & transfers (6)

### F-FIT-02 · The live settle path never flips charges to paid or creates a settlement invoice
**C-ADM-025 · gap · built-not-working · _downgraded from S1 on re-verification_**
- **Baseline:** on settle, (a) every charge tagged with the transfer id flips to `paid`, (b) a `transfer_settlement` invoice is created for the netted fees not already invoiced, (c) every invoice linked to the transfer flips to paid.
- **Observed:** the live settle path is `WithdrawalController::actionUpdate` (the settle modal links to `/withdrawal/update`). It updates ShopEarning / Earnings / Payment statuses only — the token `Charge` does not appear anywhere in `WithdrawalController`, and no Invoice is created or updated. `AdminFinanceController::actionSettle` implements (a)-(c) but is unreachable from any view, reads the `transfer_request` table its own controller comment describes as never populated, and matches charges on `transfer_request_id = (int)$id`, a value nothing writes.
- **Downgrade record (carry this):** the payout amount itself is correct — it is admin-entered, and `total_notif_fees` has already been netted at bundling time. The only demonstrable wrong outcome is that netted notification fees never flip to `paid`, so shop-facing `costsToDate()` permanently overstates. `issuableNow()` excludes them and `buildShopBalanceRow()` uses `issuable + openInvoiceDue` rather than `costsToDate`, so both the admin running balance and re-invoicing are guarded. **No wrong payout occurs.**
- **Files:** `backend/controllers/WithdrawalController.php`, `backend/views/admin-finance/_settle_modal.php`, `backend/controllers/AdminFinanceController.php`
- **Verify:** `SELECT COUNT(*) FROM transfer_request` and `SELECT COUNT(*) FROM charge WHERE transfer_request_id IS NOT NULL` — both expected 0, establishing that the fully-built settlement effects are unreachable rather than merely unexercised. **Requires P-NOTIFCHG to observe the flip at all.**
- **May be consolidated with F-FIT-03** — same secondary symptom.

### F-FIT-03 · Paying a fee invoice by card leaves its charges unpaid
**C-ADM-027 · deviation · built-not-working · _downgraded from S1 on re-verification_**
- **Baseline:** `payInvoice(id, 'card' | 'net_from_payout')` sets status `paid`, `paidAt = now`, and every charge carrying that `invoiceId` flips to `paid`.
- **Observed:** `EarningsController::actionPayInvoice()` card branch (lines 796-800) sets `payment_method`, `status = paid`, `paid_at`, `document_status = verified` and saves — it never touches the `charge` rows carrying that `invoice_id`. The admin verify path (`AdminInvoiceController::actionVerify`, lines 207-210) does run the `Charge::updateAll` flip, so the card rail is the only one that omits it.
- **Downgrade record (carry this):** the original claim that these rows stay deductible from a later payout **does not hold**. `bookingSettlement()` feeds display only; the payout is `Σ shop_earning.net_collectible_amount − total_notif_fees`, and the notification-fee query already filters `invoice_id IS NULL`. `issuableNow()->notInvoiced()` blocks re-invoicing and `openInvoiceDue()` drops paid invoices. **The residual impact is a permanently overstated `costsToDate()` — a wrong displayed number, with no money moving.**
- **Files:** `frontend/controllers/EarningsController.php`, `backend/controllers/AdminInvoiceController.php`
- **Verify — precondition P-RATES (so the invoice has non-zero fee charges).** Pay a fee invoice by card from the shop portal, then read the `charge` rows carrying that `invoice_id`. Confirmed if they remain `unpaid`.
- **Related:** SE-10 is the same omission on the subscription branch of the same method.

### F-FIT-04 · → closed by **S1 Group A**. See above.

### F-FIT-06 · A no-show booking produces no earnings row, so retained money is never released
**C-ADM-022 · gap · not-built**
- **Baseline:** status ∈ {completed, no_show, cancelled} are all "earned"; cancelled and no-show bookings release retained money minus fees.
- **Observed:** only the completion path calls `BookingCompletionService::ensureEarnings()` (`BookingController:208`, `AgentsBookingsController:386`, `GroupBookingService:877`); the cancellation path creates earnings via `BookingHelper`. The no-show branch (`BookingController::actionTransition` lines 302-306) stamps `no_show_at` and `refund_reason` only and creates no Payment / Earnings / ShopEarning, so a no-show booking never produces a settle-eligible earnings row. **The admin ledger disagrees:** `AdminFinanceController::collectableBookingIds()` counts `STATUS_NO_SHOW` bookings as collectable.
- **Files:** `frontend/controllers/BookingController.php`, `common/services/BookingCompletionService.php`, `common/components/WithdrawalBundlingService.php`, `backend/controllers/AdminFinanceController.php`
- **Verify:** requires a booking with a **retained deposit** — no rate precondition. Mark it no-show, query `shop_earning` and `earnings` for that booking id, and open the shop's settlement tab. Confirmed if no earnings row is created and the retained money never appears as withdrawable, while admin Shop Balances still counts it as collectable.

### F-FIT-07 · Settlement can be executed with neither document uploaded
**C-ADM-025 · deviation · built-not-working**
- **Baseline:** the settle control is disabled until **both** an invoice document and a bank-transfer confirmation are present.
- **Observed:** on the live settle path both uploads are optional. `Withdrawal` declares `navagoo_invoice` / `transfer_receipt` as `file` (not `required`) with no settlement scenario; `WithdrawalController::actionUpdate` wraps each upload in `if ($file)`; `_settlement_form.php` renders "Execute Settlement" with no enable/disable logic — the only interstitial is an `ngConfirm` dialog that submits unconditionally.
- **Files:** `backend/views/withdrawal/_settlement_form.php`, `backend/controllers/WithdrawalController.php`, `common/models/base/Withdrawal.php`
- **Verify:** no financial precondition. Open the settle modal on a requested transfer, attach nothing, and confirm "Execute Settlement" is enabled and the submission succeeds. **Staging is read/write authorised, but this creates a settled record — log it in `test-data.md`.**

### F-FIT-08 · The transfer payout amount does not consult `charge.status` **[common/]**
**C-ADM-023 · deviation · built-not-working**
- **Baseline:** the payout sums only rows that are `!pending` **and** `status !== 'paid'`, so already-invoiced-and-paid fees are never deducted twice.
- **Observed:** `FinanceLedgerService::bookingSettlement()` implements this correctly, but the amount stamped on a transfer request does not use it: `WithdrawalBundlingService::buildAndPersist()` accumulates `shop_earning.net_collectible_amount` and `navagoo_marketing_fees` from the legacy earnings engine and subtracts `total_notif_fees`, with no reference to `charge.status`. Fees already settled through an invoice are still netted out of the payout.
- **Files:** `common/components/WithdrawalBundlingService.php`, `common/components/FinanceLedgerService.php`
- **Verify — preconditions P-RATES, P-NOTIFCHG.** Settle a fee invoice covering notification charges, then request a transfer for the same shop and compare `total_notif_fees` on the request against the paid charge set. **Currently unobservable: `total_notif_fees` is 0.00 on all eight staging transfer requests because no notification charge exists.**

## Finance ledger (5)

### FIN-LEDGER-02 · → closed by **S1 Group C**. See above.

### FIN-LEDGER-03 · Refund-zone anchor discards the appointment's time of day **[common/]**
**C-ADM-017 · deviation · built-not-working**
- **Baseline:** the refund zone is derived from the hours between the appointment and the cancellation: `>= cancelFullHours` is full, `>= cancelPartialHours` is partial, else none. The zone sets the marketing-fee reversal fraction **and the customer refund**.
- **Observed:** `appointmentTs()` probes `['appointment_date','booking_date','date']`. `Booking` has no `appointment_date`; it stores `booking_date` (date) and `from_hour` (time) separately, so the anchor resolves to `strtotime(booking_date)` — **midnight of the appointment day** — and the slot time is discarded. The hours-before figure is shifted by the appointment's time of day, **up to 24 hours**.
- **Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/BookingController.php`
- **Verify:** no rate precondition for the **zone** (the reversal amount does need P-RATES). Set a shop's `refund_period_start` to 48 and `refund_period_end` to 24, create a navagoo-sourced booking for **20:00 two days out**, have the customer cancel roughly **30 hours** before the slot start, then inspect `booking.refund_zone`, `booking.refund_value` and the reversal row's `total_amount`. Confirmed if the zone is FULL (consistent with the midnight anchor putting the cancellation ~44h before 00:00 of the appointment day) rather than PARTIAL.

### FIN-LEDGER-04 · Processing fee is stamped on the full booking value when `amount_collected` is NULL **[common/]**
**C-ADM-012 · deviation · built-not-working**
- **Baseline:** the processing-fee basis is the amount taken on the Navagoo rail; when that is `<= 0` **no row is created at all**.
- **Observed:** `amountCollected()` returns the first non-NULL of `['amount_collected','final_collected_amount','paid_amount','total_amount']`. `amount_collected` is nullable with no default (`m260608_120100:22`) and a repo-wide search finds it assigned only in `api/` controllers, `GroupBookingService` and tests — **no frontend or backend booking-creation path sets it.** A booking left with NULL resolves its basis to the full booking value, so a processing fee is stamped where nothing was collected on the rail.
- **Files:** `common/components/FinanceLedgerService.php`, `common/migrations/db/m260608_120100_add_payment_mode_to_booking.php`
- **Verify — precondition P-RATES (a zero rate makes the wrong basis invisible).** Create a walk-in / pay-on-visit booking through the shop portal without taking an online payment, complete it, then read `charge` for that `booking_id` and `booking.amount_collected`. Confirmed if `amount_collected` is NULL and a `processing_fee` row exists whose `base_amount` equals the booking's `total_amount`.
- **Downgrade condition:** if the walk-in creation path writes 0, the fallback chain is dormant and this drops to a robustness note.

### FIN-LEDGER-05 · Bank-rail subscription charges are stamped `net_from_settlement` and enter the shop's costs **[common/]**
**C-ADM-019 · deviation · built-not-configured**
- **Baseline:** subscription charges ride the card rail (`settlementMethod: 'charge_to_card'`) and are excluded from carry-forward, transfer netting and the shop balance.
- **Observed:** the card rail is stamped `charge_to_card` as required, but the two bank-transfer rails stamp `SETTLE_NET_FROM_SETTLEMENT` — `NavagooPlansController::issueSubscriptionInvoice:715` and `SubscriptionBillingController::issueRenewalInvoice:563`. Those rows satisfy `ChargeQuery::outstanding()` and are included in `costsToDate()`, which feeds `shopBalanceView()['costsOwed']` and the shop Earnings screen's costs figure. They carry an `invoice_id`, so `notInvoiced()`-scoped paths (`issuableNow`, `issueShopInvoice`) do exclude them.
- **Files:** `frontend/controllers/NavagooPlansController.php`, `console/controllers/SubscriptionBillingController.php`, `common/components/FinanceLedgerService.php`
- **Verify:** no rate precondition — subscription amounts are real on staging. Subscribe a shop on the bank-transfer rail, read `costsToDate()` / the shop Earnings costs figure before and after, and check the charge row's `settlement_method`. Confirmed if the costs-owed figure moves by the subscription amount and the row reads `net_from_settlement`. **Also request a transfer for that shop afterwards** — if `WithdrawalBundlingService` pulls the row into payout netting, the severity rises.

### FIN-LEDGER-07 · A cancelled package-redemption booking picks up a second marketing fee **[common/]**
**C-ADM-020 · gap · not-built**
- **Baseline:** `deriveBookingCharges` returns an empty list for any booking carrying a package link, so a redemption visit cannot pick up ordinary booking fees.
- **Observed:** `deriveBookingCharges()` has no package-link guard. A package-redemption booking cancelled through the shop portal reaches `BookingController::actionCancel:780`, which calls it and builds a second marketing fee — **on the full booking value rather than the per-session price** — on top of the redemption fee already stamped by `buildPackageRedemptionCharges()`.
- **Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/BookingController.php`
- **Verify — precondition P-RATES.** Cancel a package-redemption booking through the shop portal and count `marketing_fee` rows for that `booking_id`. Confirmed if two exist, one on the full booking value.

## Notifications (3)

### NOTIF-01 · The in-app channel is gated on the paid-channel approval status **[common/]**
**C-ADM-055 · deviation · built-not-working**
- **Baseline:** `channelUsable(trigger, channel)` = the channel exists AND enabled AND `status === 'approved'`. `in_app` is always created and forced back to `approved` (C-ADM-054), so the free in-app channel never depends on the SMS/WhatsApp approval gate; a shop-audience trigger resolves to `['in_app']` unconditionally.
- **Observed:** `NotificationDispatchService::channelUsable()` returns false for **every** channel, `in_app` included, unless `approval_status === 1`. Consequences: (a) a newly created trigger defaults to APPROVAL_PENDING, so its authored in-app copy does not send; (b) `actionApprove()` refuses to approve unless `hasPaidTemplate()` is true, so **an in-app-only trigger created through the admin form can never reach approved and can never fire — a dead end with no in-UI explanation**; (c) a paid-template edit re-pends the trigger and silently stops the free in-app channel too; (d) shop-audience alerts (e.g. `settlement_received`) are subject to the same gate. Migration `m260721_215500` works around this in production by force-approving 8 seeded triggers, and its docblock names the inconsistency. `NotificationTrigger::channelUsable()` (line 355) implements the baseline rule, **so two contradictory gates coexist and the wrong one is on the send path.**
- **Files:** `common/components/NotificationDispatchService.php`, `common/models/NotificationTrigger.php`, `backend/controllers/NotificationTriggerController.php`, `common/migrations/db/m260721_215500_approve_notif_triggers_for_inapp.php`
- **Verify:** no financial precondition (in-app is free). Create a new shop-audience (in-app only) trigger via Admin ▸ Notifications ▸ Add trigger, fill only the in-app EN/AR bodies, save, attempt Approve, then fire the event. Confirmed if Approve is refused with "Add an SMS or WhatsApp template before approving" while the trigger stays PENDING, **and** no in-app notification row is written when the event fires.

### NOTIF-03 · Approval is per-trigger, not per-channel **[common/]**
**C-ADM-054 · deviation · built-not-configured**
- **Baseline:** approval status is stored per channel (`NotifChannelConfig.status`). Approving one channel flips only that channel; editing the SMS body re-pends SMS alone and leaves an approved WhatsApp template approved.
- **Observed:** a single per-trigger `approval_status` column governs both paid channels. `actionApprove()` (guarded only by `hasPaidTemplate()`, satisfied by **either** channel) marks SMS and WhatsApp approved together, so a WhatsApp-only review also releases an SMS body; conversely `actionUpdate()` re-pends both when either body changes. The per-channel columns `sms_approval_status` / `wa_approval_status` were added and backfilled by `m260728_100100`, whose docblock records that wiring the application code to them is a **deliberate follow-up** — no model rule, controller action, dispatch path or view reads them. The row view documents the delta in its own header comment.
- **Files:** `backend/controllers/NotificationTriggerController.php`, `common/models/NotificationTrigger.php`, `backend/views/notification-trigger/_row.php`, `common/migrations/db/m260728_100100_add_notification_per_channel_approval.php`
- **Verify:** no financial precondition. Author both an SMS and a WhatsApp body on one trigger, Approve, then edit only the WhatsApp body by one character and save; re-inspect both channel badges. Confirmed if one Approve marks both channels Approved and the WhatsApp-only edit returns **both** to Pending. Also read `notification_trigger.sms_approval_status` / `wa_approval_status` directly to confirm the columns exist and are stale.

### NOTIF-04 · "Fires" is free text, so a trigger can be authored that nothing dispatches **[common/]**
**C-ADM-053 · deviation · built-not-configured**
- **Baseline:** a trigger carries an `eventBasis` from a closed enum {on_booking_confirmed, on_booking_cancelled, on_payment, on_settlement, time_before_appointment, on_invitation}; the admin picks a basis, and the basis binds the trigger to a platform event.
- **Observed:** the admin form's "Fires" control is a **free-text input bound to `event_key`**, auto-slugged from the English name (`fillEventKey` plus the form's JS). Dispatch is by literal key match against a fixed set of call sites (`booking_completed`, `booking_cancelled`, `booking_cancelled_by_customer`, `settlement_received`, plus the timing-column-driven cron), **so a trigger authored with any other name produces a key that nothing dispatches — it saves, appears active in the catalogue, and never fires, with no validation or warning.** An `event_basis` column exists and was backfilled by `m260728_100300` but is absent from the model rules, the form and every read path; the row view still derives the basis line from a hard-coded PHP `event_key` map (`_row.php:46-54`).
- **Files:** `backend/controllers/NotificationTriggerController.php`, `backend/views/notification-trigger/_form.php`, `backend/views/notification-trigger/_row.php`, `common/migrations/db/m260728_100300_add_notification_event_basis.php`
- **Verify:** no financial precondition. Create a trigger with a name that slugs to a key outside the dispatch set, activate it, fire the nearest real event, and confirm no notification is produced and no warning was shown at save time.

## System & RBAC (5)

### F-RBAC-01 · A custom role yields a login with no reachable page
**C-ADM-051 · deviation · built-not-working**
- **Baseline:** `createRole` produces a usable custom role; a role's stored permission set is what the nav filter and every runtime check read, so a role holder sees exactly their grants.
- **Observed:** two independent mechanisms. (a) The backend catch-all access rule allows only `manager`, `administrator`, `shopOwner` (`backend/config/web.php:133-136`), and a custom role is created as a standalone RBAC item with `loginToBackend` and catalogue permissions only — never a child of `manager` (`RoleForm::create:109-125`) — so `can('manager')` is false and AccessControl denies every controller. (b) Even if a rule matched, `User::checkMenuPermissions` (`common/models/User.php:801`) returns true only when the holder's first RBAC role name is literally `'manager'`. **Both `actionAssignRole` (`:219-250`) and the Add-user modal offer custom roles, and `actionAssignRole` revokes the previously held assignable role first — so moving an existing manager onto a custom role removes their access to the whole admin portal.**
- **Files:** `backend/models/RoleForm.php`, `backend/controllers/UsersRolesController.php`, `backend/config/web.php`, `common/models/User.php`
- **Verify:** no financial precondition. Create a custom role with two catalogue permissions (e.g. `shop_index`, `booking_index`), create a staff login with it, sign in, and record every page reachable and every nav entry rendered. Expected: authentication succeeds (`loginToBackend` is attached) but every controller returns 403 and the sidebar renders Home only. **Then repeat the role move on a throwaway second account currently holding `manager`.**
- **Withdraw condition:** if the granted pages open, some path re-adds `manager` and the finding is void.

### F-RBAC-02 · Fine-grained authorization reads a per-user CSV, not the role registry
**C-ADM-045 · deviation · built-not-working**
- **Baseline:** the matrix is a runtime registry and every runtime authorization check reads the registry, never a per-subject copy.
- **Observed:** `User::checkMenuPermissions` (`:788-805`) reads a comma-separated string on the user row (`user.roles`) and additionally requires the holder's RBAC role name to equal `'manager'`. The CSV is stamped **once at user creation** from the picked role (`ManagerForm::save:271-275`) and is never re-synced, so editing a role's permission set afterwards does not change what any existing holder can see. Roles other than `manager` — including every custom role — return false regardless of the registry.
- **Files:** `common/models/User.php`, `backend/models/ManagerForm.php`
- **Verify:** no financial precondition. Sign in as a `manager` whose `user.roles` CSV holds a known subset, record the nav entries rendered, then edit that subset via the Managers editor and re-check **without recreating the user**. Confirmed if the nav is unchanged.

### F-RBAC-03 · Sixteen nav-gating permission keys are absent from the grantable catalogue
**C-ADM-046 · gap · not-built**
- **Baseline:** every nav entry's required permission is a member of the closed permission catalogue, so any entry can be granted to a role.
- **Observed:** sixteen keys the admin menu gates on are absent from `ManagerForm::permissionCatalogue` and can never be granted to anyone — the entries are effectively administrator-only: `user_people`, `admin-finance_shop-balances`, `admin-finance_transfer-requests`, `admin-invoice_index`, `admin-charge_index`, `earnings_index`, `cancellations_and_refunds_index`, `marketing_index`, `city_index`, `district_index`, `push-notification_index`, `demo-request_index`, `faq_support`, `settings_workflow`, `settings_colors`, `settings_policies` (plus `live-chat_index`, out of scope per the exclusion list). Six further entries (Dashboard, P&L, Costs, Invitations, Events, Users & Roles) carry **no delegable key at all** and are gated purely on `can('administrator')`.
- **Files:** `backend/views/layouts/menu/Menu.php`, `backend/models/ManagerForm.php`
- **Verify:** no financial precondition. Sign in as a `manager` with a known CSV and record every nav entry rendered plus the section headers. Expected: a flat, header-less list containing only entries whose OR-list intersects the CSV, with Dashboard / P&L / Costs / Invitations / Events / Users & Roles and all four section headers absent. (Same probe confirms F-RBAC-11.)

### F-RBAC-04 · Four staff roles exist where the baseline defines seven across two scopes
**C-ADM-045 · deviation · not-built**
- **Baseline:** seven built-in roles across two scopes — platform: `super_admin`, `admin`, `finance`, `support`; shop: `owner`, `manager`, `front_desk` — each with a stored scope, an English and an Arabic label, and `builtin: true`.
- **Observed:** four staff roles exist (`administrator`, `manager`, `shopOwner`, `shopManager`; a fifth, `user`, is filtered out of the screen). **No `finance`, `support` or `front_desk` role exists, so the baseline's read-only and money-only personas have no representation.** `scope` is not stored — it is derived at render time from a hard-coded name list (`UsersRolesController.php:129-138`); `builtin` is inferred from a `shop_` name prefix (`RoleForm::isCustomRole:44-47`); role labels are single-language, mapped in the view (`index.php:59-68`) rather than stored bilingually as the project's bilingual rule requires.
- **Files:** `common/migrations/rbac/m150625_214101_roles.php`, `common/migrations/rbac/m260728_120000_shop_manager_role.php`, `backend/controllers/UsersRolesController.php`, `backend/views/users-roles/index.php`
- **Verify:** no financial precondition. Enumerate the roles offered on Users & Roles and check for the three missing personas. **Product decision on which personas are actually wanted before engineering.**

### F-RBAC-05 · No default role→permission grid; the matrix shows synthetic sets
**C-ADM-047 · gap · not-built**
- **Baseline:** a default role→permission grid seeds each built-in role with a specific, diffable grant set.
- **Observed:** `UsersRolesController.php:104-119` returns a **synthetic** set per role name — `administrator` and `manager` both return the entire 25-key catalogue, `shopOwner`/`shopManager` return `['manageShop']`, `user` returns `[]` — and only a custom role's set is read from RBAC. The rbac migrations grant just `loginToBackend` (manager, administrator), `editOwnModel` (user) and `manageShop` (shopOwner, shopManager); **no catalogue permission is attached to any built-in role in the graph.** The matrix therefore shows `manager` as holding everything while an individual manager's real grant is a per-user CSV subset, and there is no differentiated grant set to compare against the baseline's finance/support rows.
- **Files:** `backend/controllers/UsersRolesController.php`, `common/migrations/rbac/m150625_215624_init_permissions.php`, `common/migrations/rbac/m260728_120000_shop_manager_role.php`
- **Verify:** no financial precondition. Compare the matrix's displayed grants for `manager` against the RBAC graph and against a specific manager's `user.roles` CSV. **Schedule with F-RBAC-02 — the CSV is the real gate.**

## Catalogue & geography (5)

> **Shared root — F-CAT-01 and F-CAT-02.** Both stem from `ShopCategoryController::parentOptions()`
> excluding every category that already has a child. One correction to that method addresses both.

### F-CAT-01 · A shop category can hold at most one service category through the admin form
**C-ADM-040 · deviation · built-not-working**
- **Baseline:** a ShopCategory holds many ServiceCategories; the mockup's parent select lists every top-level group, excluding only the row being edited (`Catalogue.tsx:161-162`).
- **Observed:** `parentOptions()` collects the distinct set of `parent_id` values (ids that **already** have at least one child) and then `continue`s past any of them — the comment reads "Exclude rows that are already parents (would create a 2nd level)". Rows that are children are already excluded by the `parent_id IS NULL` clause, so this second filter removes exactly the valid groups. Once a shop category has one child it disappears from the "Parent group" select, and `applyParentId()` silently coerces a non-listed pick to NULL, so a second service category created "under" it is saved as a top-level category.
- **Files:** `backend/controllers/ShopCategoryController.php`, `backend/views/shop-category/_form.php`
- **Verify:** no financial precondition. Create shop category G, add service category A under it, then open the create form again and try to add B under G. Confirmed if B saves as a top-level row.

### F-CAT-02 · Editing a service category's name silently detaches it from its parent
**C-ADM-040 · deviation · built-not-working**
- **Baseline:** a ServiceCategory nests under exactly one ShopCategory via `shopCategoryId`; editing the category's name does not change its parent.
- **Observed:** same root cause as F-CAT-01. On `actionUpdate` of child C with parent P, `parentOptions()` excludes P, so `Html::dropDownList` renders with the current value absent and the "— None (top-level) —" prompt selected. Submitting posts `parent_id = ''` and `applyParentId()` sets `parent_id = null`. **Editing a service category to fix a typo silently promotes it to a top-level shop category, with no warning and no way to re-select the original parent.**
- **Files:** `backend/controllers/ShopCategoryController.php`, `backend/views/shop-category/_form.php`
- **Verify:** no financial precondition. Open A for edit, change nothing but the name, save, then check A's `parent_id`. Confirmed if it is NULL.

### F-CAT-03 · Catalogue branch is derived at read time, so a new category appears in neither branch
**C-ADM-041 · gap · not-built**
- **Baseline:** `ShopCategory.shopType` is an authored `'men' | 'women'` value; only the two gendered branches are authored, and categories with the same name in different branches are distinct rows.
- **Observed:** `shop_category` has no branch column. `actionCatalogue` derives a category's branch at read time from the gender of the shops offering its services (service → shop_service → shop.gender), keeping a parent when it or any child matches. Two consequences: a category with no services yet matches neither branch, so **a just-created category is invisible on the Catalogue screen under both Female and Male** (create redirects to `/shop-category/index`, where it does show, so the divergence is confined to the tree view); and a category whose services are offered by both male and female shops renders in both branches as the same row, so the branches are not the two disjoint authoring trees the baseline describes.
- **Files:** `backend/controllers/ShopCategoryController.php`, `backend/views/shop-category/catalogue.php`, `common/models/base/ShopCategory.php`
- **Verify:** no financial precondition. Create a new shop category from `/shop-category/catalogue` with no services attached, then reload `?branch=women` and `?branch=men`. Confirmed if it appears in neither.

### F-CAT-04 · Deleting a shop category dead-ends on the assignment tables
**C-ADM-043 · gap · not-built**
- **Baseline:** deleting a ShopCategory strips the id from every shop's `shopCategoryIds` and removes every per-shop image override — never a dangling reference.
- **Observed:** `actionDelete` uncategorises services and deletes the child and parent rows, but never touches `shop_category_assignment` (shop↔category membership, written by backend `ShopController` and frontend `SignupForm`) or `service_category_assignment` (per-shop image overrides). Both declare an FK onto `shop_category(id)` with no ON DELETE clause, i.e. RESTRICT. **For any category at least one shop is assigned to — the ordinary case — the DELETE raises, the transaction rolls back and the admin gets a "Could not delete category" danger flash.** Where the FK is absent in a given database, the rows survive as dangling references instead.
- **Files:** `backend/controllers/ShopCategoryController.php`, `common/migrations/db/m250727_192735_create_shop_category_assignment_table.php`, `common/migrations/db/m251207_194159_create_service_category_assignment_table.php`
- **Verify:** no financial precondition. First run `SHOW CREATE TABLE shop_category_assignment` and `SHOW CREATE TABLE service_category_assignment` and confirm the `ibfk_1` FKs actually exist — the later `parent_id` migration notes the DB user may lack the REFERENCES privilege, so the raw-SQL FKs may not have been created everywhere. Then delete a category a shop is assigned to. FK present → dead-end flash. FK absent → dangling rows.
- **Blocked sub-question:** whether `deleteWithRelated()` deletes `hasMany`-related rows — `ShopCategory::relationNames()` returns `['services','shops']` and `getShops()` is `hasMany(Shop, ['category_id' => 'id'])`. If it does, deleting a shop category attempts to delete every Shop pointing at it — **a data-loss path.** `vendor/` is not installed in the audit checkout; run `composer install` and read `RelationTrait::deleteWithRelated()` before touching this area.

### F-CAT-05 · The city edit modal cannot save any legacy city
**C-ADM-082 · deviation · built-not-working**
- **Baseline:** `updateCity` persists the city's bilingual name and district list (and records a field-level diff).
- **Observed:** `City::rules()` returns `array_replace_recursive(parent::rules(), [[['name_ar'],'string','max'=>255], [['active'],'integer'], [['active'],'default','value'=>1]])`. Because `array_replace_recursive` merges the parent's rule rows **positionally**, row 1 of the base rules (`[['name','slug','meta_description'],'string','max'=>255]`) is overwritten element-by-element into `[['active','slug','meta_description'],'integer','max'=>255]` — `slug` and `meta_description` acquire an integer validator and `name` loses its string rule. Existing cities carry non-numeric slugs (the seed migration inserts `'أبها'`, `'وادى-الدواسر'`), so `CityController::actionSaveGeo`'s `$model->save()` fails validation and returns `{ok:false, error:'Slug must be an integer.'}`. **The Save button on the city edit modal cannot succeed for any legacy city.** Creating a new city is unaffected (slug is empty and the number validator skips empty values).
- **Files:** `common/models/City.php`, `common/models/base/City.php`, `backend/controllers/CityController.php`
- **Verify:** no financial precondition. Open a seeded city with a non-numeric slug (e.g. Riyadh) in the Geography edit modal, change only the Arabic name, press Save. Confirmed by a JSON error mentioning Slug. **Derived by reading PHP, not executing it — this probe is what turns it from a prediction into an observation.**
- **Same construct as F-CAT-06 (S3):** see `REPORT_ADMIN.md` §7.5.

## Adjacent admin (3)

### F-ADJ-01 · No admin mutation emits an audit event **[common/]**
**C-ADM-075 · gap · built-not-configured**
- **Baseline:** every admin mutation emits a typed audit event carrying a typed action, a typed target `{type,id}`, a resolved actor, a surface and an optional structured payload; update actions carry a field-level `{changes: diff}` payload; surface is explicit (`system` for a threshold auto-issue, `admin` for a manual issue, `shop` for a shop-initiated change).
- **Observed:** the Events screen reads `timeline_event`, written by only four model hooks — `User::notifySignup/notifyRequest/notifyDeletion` (`User.php:640,656,672`), `Shop::notifyNewShop` (`base/Shop.php:711`), `Branch::notifyNewShop` (`:223`) and `TechnicalSupport` (`:133`) — all stamped `category='user'`. **No admin-portal mutation emits anything**: the global commercial-config save, RBAC role and permission changes, transfer settlement, invoice verification, the classification override, shop activation, and category/city/plan/offer/notification-trigger CRUD all write no event. `AuditLogService::emit()` — which writes actor/scope/shop_id/action/entity/before_json/after_json into `audit_log` (`m260709_175301`) and is registered as the `auditLog` component at `common/config/base.php:218-219` — has **zero call sites** in `backend/`, `common/`, `frontend/`, `console/` or `api/`. Field-level diffs exist only for the two payment-processing-fee columns, via `CommercialRateLog`, which is not surfaced on the Events screen. There is also **no way to record an event at an instant other than now**: `TimestampBehavior` stamps `created_at` on insert and `AddToTimelineCommand` exposes no timestamp parameter, so a catch-up emitter cannot record when the action actually occurred.
- **Highest-leverage item in S2.** This one absence also produces F-RBAC-09, F-CAT-08, CF-CC-08, SE-15 and F-ADJ-07 — see `REPORT_ADMIN.md` §7.6.
- **Files:** `common/components/AuditLogService.php`, `common/config/base.php`, `common/commands/AddToTimelineCommand.php`, `backend/controllers/TimelineEventController.php`, `backend/controllers/CommercialConfigController.php`, `backend/controllers/UsersRolesController.php`, `backend/controllers/AdminFinanceController.php`
- **Verify:** no financial precondition. Perform one admin mutation of each kind and query `audit_log` and `timeline_event`. Confirmed if both are unchanged.

### F-ADJ-02 · The timeline writer upserts, so repeat events overwrite rather than append **[common/]**
**C-ADM-075 · deviation · built-not-working**
- **Baseline:** mutations emit an audit event — one record per occurrence, timestamped when that occurrence happened. An audit log is append-only by nature.
- **Observed:** `AddToTimelineCommand::handle` (`:36-44`) first runs `TimelineEvent::find()->where(['event' => $command->event, 'user_id' => $command->user_id])->one()` and reuses that row when found, so a repeat of the same `(event, user_id)` pair **overwrites** the previous record. Because `TimestampBehavior` sets `createdAtAttribute` on insert only (`updatedAtAttribute` is null), the surviving row also keeps the timestamp of the first occurrence. The collision is real for `User::notifyDeletion` (`User.php:672-682`), which passes **no `user_id` at all** — every user deletion writes to the same single row, so the Events screen can only ever show one deletion, dated at the first one. The other three emitters key on a per-entity id so they do not currently collide.
- **Files:** `common/commands/AddToTimelineCommand.php`, `common/models/TimelineEvent.php`, `common/models/User.php`
- **Verify:** no financial precondition. Delete two different users in sequence, then `SELECT id, event, user_id, created_at FROM timeline_event WHERE event = 'delete'`. Confirmed if exactly one row exists carrying the second user's payload but the **first** deletion's `created_at`.
- **Downgrade condition:** two rows would mean the upsert does not collide in practice, and this drops to a latent-risk note.

### F-ADJ-04 · The shop form edits a marketing-rate column the fee engine does not read
**C-ADM-080 · deviation · built-not-working**
- **Baseline:** activation writes the per-shop commercial terms including `marketingFeeRatePct`, and subsequent fees use them; the admin surface that displays and edits a shop's marketing rate is the rate the marketing fee is charged at.
- **Observed:** the `shop` table carries two marketing-rate columns and **the admin edits the inert one.** `FinanceLedgerService::marketingRatePct` (`:170-180`) reads `shop.platform_commission`, falling back to `commercial_config.marketing_fee_pct`. The separate column `shop.marketing_fee_rate_pct` is seeded to a literal 5 at shop creation (`seedShopProvisioning :1477`), assigned from POST by `applyCommercialOverrides` (`:1493-1496`), rendered as the labelled "Marketing Fee Rate %" input with the help text "Per-shop marketing fee. Blank = inherit global." (`_form.php:336-342`) and rendered as **the** marketing-rate column of the shops directory (`index.php:275-280`) — but a repo-wide grep finds no fee, earnings, settlement or invoice path that reads it. The same form also renders `platform_commission` (`_form.php:318`, label "Platform Commission"), and the activation form renders it as "Navagoo marketing fee" (`activate.php:136`).
- **Files:** `backend/views/shop/_form.php`, `backend/views/shop/index.php`, `backend/views/shop/activate.php`, `backend/controllers/ShopController.php`, `common/components/FinanceLedgerService.php`
- **Verify — preconditions P-RATES (implicitly satisfied by the probe itself) and P-MARKETING (a marketing row must be produced at all — currently requires a cancel path or S1 Group C's fix).** Set "Platform Commission" to 5 and "Marketing Fee Rate %" to 8 on one shop, save, then create and complete a navagoo-sourced booking of 500 and read the charge row's `rate_snapshot` and `total_amount`. Repeat with the two values swapped. Confirmed if `rate_snapshot = 5` while the shops-directory Marketing column displays 8.
- **Withdraw condition:** if `rate_snapshot` follows the 8, the finding is wrong and should be withdrawn.

---

# S3 — Moderate

33 items. Compact form: **B** = baseline, **O** = observed. Every item names its verification
precondition, or states that none is needed.

## Subscriptions & entitlements (6)

**SE-07 · C-ADM-007 · deviation · built-not-working · Walk-in payment toggles default wrong and are never persisted [common/]**
- **B:** walk-in toggles are `walkinOnline` (default false), `walkinDeposit` (default false), `walkinOnVisit` (default true), stored per shop.
- **O:** `m260628_200000` creates all three columns notNull default 1, so walk-in online and walk-in deposit default **on** where the baseline defaults them off. The three attributes are also omitted from `ShopPaymentSettings::scenarios()[SCENARIO_DEFAULT]`, while `SettingsController` line 327 populates the model with `$paymentModel->load($post)` — mass assignment skips them, **so the three walk-in checkboxes rendered in `frontend/views/settings/_payments.php` are displayed but never persisted by a save.**
- **Files:** `common/models/ShopPaymentSettings.php`, `frontend/controllers/SettingsController.php`, `frontend/views/settings/_payments.php`, `common/migrations/db/m260628_200000_add_shop_walkin_time_fields.php`
- **Verify:** no precondition. Toggle the three walk-in checkboxes to the opposite of their current values in shop Settings ▸ Payment Methods, save, re-read `shop_payment_settings`. Confirmed if the `walkin_*` columns are unchanged.

**SE-12 · C-ADM-006 · deviation · built-not-configured · The enforced feature set is not the baseline's live set [common/]**
- **B:** `LIVE_FEATURES = {multi_specialist_calendar, deposits_pos, deals_promo}` are the only keys with a real gateable surface; `multi_branch`, `messaging_campaigns` and `advanced_reports` are registered but deliberately ungated.
- **O:** `EntitlementService::LIVE_FEATURES` holds the correct three keys but **has no reader**. The gate that runs, `EntitlementFilter::FEATURE_MAP`, enforces `promo_codes_packages`, `advanced_reports`, `deals_promo`, `messaging_campaigns`, `social_connector` and `multi_branch`. Only `deals_promo` overlaps; `multi_specialist_calendar` and `deposits_pos` are unenforced, and three keys the baseline marks register-only do block surfaces. `promo_codes_packages` is a STARTER key, so that entry can never block a subscribed shop.
- **Files:** `common/components/EntitlementService.php`, `frontend/components/EntitlementFilter.php`
- **Verify:** no precondition. Diff the two constants; then confirm at runtime that a shop lacking `deposits_pos` still reaches the deposits surface.

**SE-13 · C-ADM-062 · gap · not-built · Subscription plans have no Arabic name**
- **B:** a plan carries bilingual `name` / `nameAr`, both mandatory — the save is blocked until each is non-empty.
- **O:** `navagoo_subscription_plan` has a single `name` column, the admin modal renders one "Plan name" input (`plans.php:273-276`), and `savePlan()` validates only that trimmed `name` is non-empty. There is no Arabic plan name in the model, the form, the save handler or any message file, so the plan name renders identically in the Arabic UI. **Also sits against the repo-wide bilingual requirement in `CLAUDE.md`.**
- **Files:** `common/models/NavagooSubscriptionPlan.php`, `backend/views/shop/plans.php`, `backend/controllers/ShopController.php`
- **Verify:** no precondition. Switch the portal to Arabic and confirm the plan name renders in English.

**SE-14 · C-ADM-001 · deviation · built-not-configured · A newly created plan is seeded with an empty feature list**
- **B:** `featuresForTier(tier)` seeds a new plan's `enabledFeatures`; an existing plan keeps its current set on save.
- **O:** `savePlan()`'s create branch sets `$fields['features'] = new JsonExpression([])` — empty regardless of the chosen tier (`ShopController.php:1803`). The keep-on-edit half is correct. `EntitlementService::shopFeatures()` masks this at read time by falling back to `featuresForTier()` when the stored list contains no canonical keys, **so a freshly created plan behaves like its tier until an admin ticks a single box — at which point the stored list becomes authoritative and the rest of the tier's features disappear.**
- **Files:** `backend/controllers/ShopController.php`, `common/components/EntitlementService.php`
- **Verify:** no precondition. Create a plan on a tier, read its `features` JSON (expect `[]`), then tick one matrix box and re-check what a subscriber on that plan can open.

**SE-15 · C-ADM-073 · gap · not-built · A plan edit notifies no subscriber and records no event**
- **B:** a plan edit touching price, discounts or `enabledFeatures` appends an in-app notification to every affected subscriber (active / free_period / past_due) stating the terms change at the next renewal, and emits `subscription.plan_changed` plus a `plan.updated` field-level diff.
- **O:** `savePlan()` and `actionSavePlanFeatures()` write the plan row and set a session flash. Neither queries `shop_subscription`, writes a notification, or records an audit event. Shops learn of a price or feature change only when it takes effect.
- **Files:** `backend/controllers/ShopController.php`
- **Verify:** no precondition. Edit a plan's price and check `notification` and `audit_log` for the subscribers. **Part of the audit-event cluster — see `REPORT_ADMIN.md` §7.6.**

**SE-16 · C-ADM-066 · deviation · built-not-configured · Trial length has no global fallback and `trial_consumed` is not always set**
- **B:** trial length = `shop.trialConsumed ? 0 : (plan.freePeriodDays || config.subscriptionDefaultTrialDays)`; `trialConsumed` is set as soon as the subscription leaves `free_period` and blocks repeat trials thereafter.
- **O:** `NavagooPlansController.php:319` computes `$trialDays = trial_consumed ? 0 : (int) $plan->free_period_days` **with no global fallback**, so a plan authored with `free_period_days = 0` gives a fresh shop no trial at all. `trial_consumed` is written 1 on **entering** the trial (line 343) and never on the no-trial branch, so a shop whose first subscription was to a zero-trial plan still reads `trial_consumed = 0` and can draw a full trial on a later plan.
- **Files:** `frontend/controllers/NavagooPlansController.php`
- **Verify:** no precondition. Subscribe a fresh shop to a plan with `free_period_days = 0`, then subscribe it to a plan with a trial and confirm the trial is granted.

## Finance ledger (2)

**FIN-LEDGER-08 · C-ADM-020 · gap · not-built · Package sales inside the grace window are not waived [common/]**
- **B:** in grace, a package sale's marketing fee is emitted as a zero-amount audit row with basis and rate still stamped; the processing fee is never waived.
- **O:** `buildPackagePurchaseCharges()` never consults `computeInGrace()`. A package sold to a navagoo-sourced customer inside the shop's onboarding grace window is charged the **full** marketing fee. The equivalent waiver is present on the booking path via the `inGrace` opt in `buildMarketingFee()`.
- **Files:** `common/components/FinanceLedgerService.php`
- **Verify — precondition P-RATES.** Sell a package to a navagoo-sourced customer at a shop inside its grace window and read the resulting `marketing_fee` row's `total_amount`. **At zero rates the row is 0.00 either way — set a rate first.**

**FIN-LEDGER-09 · C-ADM-017 · deviation · built-not-working · Re-deriving an old cancellation rebuilds no reversal row [common/]**
- **B:** the reversal fraction follows from the zone at the moment of cancellation, so re-deriving an old cancellation reproduces the same rows.
- **O:** `deriveBookingCharges()` calls `refundZoneFor($booking, $shop)` **without a `cancelAt` argument**, so the zone is computed from `time()` rather than the booking's stamped `cancelled_at`. On the admin classification-override re-derive path (`UserController.php:276-285`), which deletes and rebuilds unpaid/pending marketing rows for a historically cancelled booking, the appointment is already in the past, `refundZoneFor` short-circuits to NO_REFUND at line 511, and **no reversal row is rebuilt.** The group cancel paths do pass `$now` explicitly for the refund figure, but the ledger re-derivation call still omits it.
- **Files:** `common/components/FinanceLedgerService.php`, `backend/controllers/UserController.php`
- **Verify — precondition P-RATES.** Run the admin classification override on a historically cancelled navagoo-sourced booking and compare the rebuilt rows against the originals.

## Finance — invoices & transfers (7)

**F-FIT-09 · C-ADM-032 · gap · not-built · No scheduled billing/threshold-reconciliation pass**
- **B:** the billing pass is a requirement and belongs to scheduled execution; threshold reconciliation runs per shop as a pass.
- **O:** there is **no console command and no cron entry** for fee-invoice billing or threshold reconciliation. `console/config/schedule.php` schedules booking notifications, paymob queue, wages, freeze reminders, notification dispatch, subscription billing and package expiry only. The threshold pass runs solely as a side effect of an admin opening Shop Balances (`actionShopBalances:112-127`); the manual per-shop "Issue invoice now" button is the only other issuance path, and no all-shops manual run exists.
- **Files:** `console/config/schedule.php`, `backend/controllers/AdminFinanceController.php`
- **Verify:** no precondition. Read `schedule.php`. **Note:** per the exclusion list, the mockup's "Run billing cycle" button is a demo artifact — billing itself is the requirement, and in the portal it belongs to scheduled execution, not a UI button. **Schedule with CF-CC-03.**

**F-FIT-10 · C-ADM-028 · gap · not-built · Fee invoices carry no `issued_at`, `due_at` or `period`**
- **B:** an issued invoice carries `dueAt = now + config.invoiceDueDays`.
- **O:** `issueShopInvoice()` and `actionSettle()` set `shop_id`, `type`, `amount_due`, `vat_amount`, `earnings_applied`, `applied_booking_ids`, `status` and document paths — never `issued_at`, `due_at` or `period`. Only the subscription rail writes those columns (`NavagooPlansController:696-697`, `SubscriptionBillingController:545-546`). `commercial_config.invoice_due_days` is defined and admin-editable but unread for fee invoices, so a settlement invoice has no due date and no path to `overdue`; the Invoices tab also sorts on `issued_at DESC`, which is null for every fee invoice.
- **Files:** `backend/controllers/AdminFinanceController.php`, `backend/controllers/AdminInvoiceController.php`, `common/models/CommercialConfig.php`
- **Verify — precondition P-RATES (a fee invoice must have something to invoice).** Issue a fee invoice and read `issued_at` / `due_at` / `period` (expect NULL) and its position in the Invoices tab sort.

**F-FIT-11 · C-ADM-024 · gap · not-built · Open invoices are not locked to an in-flight transfer request [common/]**
- **B:** any open non-settlement invoice whose charges are being netted is locked to the request (`invoice.transferRequestId` set) so the shop cannot pay it separately.
- **O:** no invoice↔transfer link exists in either direction at request time. `invoice` has no `transfer_request_id` column (the FK runs the other way, `transfer_request.invoice_id`), and nothing in the request-creation path inspects or locks open invoices. **A shop can pay an open threshold/manual invoice while the same fees are in flight in a settlement request.**
- **Files:** `common/models/base/Invoice.php`, `common/models/base/TransferRequest.php`, `common/components/WithdrawalBundlingService.php`
- **Verify — precondition P-RATES.** Leave an open threshold invoice for a shop, request a transfer, then pay the invoice from the shop portal and confirm both succeed. **Same missing-link family as S1 Group F.**

**F-FIT-12 · C-ADM-029 · deviation · built-not-working · A second open threshold invoice can be issued**
- **B:** a threshold invoice is auto-issued only when the shop has no open (`status !== 'paid'`) invoice with `trigger === 'threshold'`.
- **O:** `reconcileBilling()` guards on `carried < threshold || issuable <= 0.005`. Immediately after issuance the fee rows carry an `invoice_id`, so `issuable` drops to 0 and a repeat pass is a no-op; **but once new fees accrue while the first threshold invoice is still unpaid, the shop is still over its limit and a second open `threshold` invoice is issued.** No open-threshold-invoice check exists.
- **Files:** `backend/controllers/AdminFinanceController.php`
- **Verify — precondition P-RATES.** Issue a threshold invoice, accrue further fees without paying it, reopen Shop Balances, and count open `threshold` invoices for the shop.

**F-FIT-13 · C-ADM-024 · gap · not-built · Package sales contribute fees but no collectable earnings or payout eligibility [common/]**
- **B:** package sales past the hold ride the same settlement rail — a shop with only an eligible package can still request a payout — and `collectable` includes `Σ(amountCollected − refundValue)` over available package purchases.
- **O:** `shop_earning` rows are created only by `BookingHelper` (booking earnings and tip payments); `buildPackagePurchaseCharges()` raises the fee rows for a package sale but **no earnings row**, so `eligibleEarnings()` can never select one. On the admin side `collectableEarnings()` iterates Bookings only. Package revenue therefore contributes fees to the balance but no collectable earnings and no payout eligibility.
- **Files:** `common/helpers/BookingHelper.php`, `common/components/WithdrawalBundlingService.php`, `backend/controllers/AdminFinanceController.php`, `common/components/FinanceLedgerService.php`
- **Verify:** no rate precondition for the **eligibility** half. Sell a package at a shop with no bookings, wait past the hold, and attempt Request transfer. Confirmed if nothing is eligible.

**F-FIT-14 · C-ADM-028 · deviation · n/a · A fully-netted invoice is issued `paid` with its charges flipped**
- **B:** the invoice is issued `status: 'unpaid'`, `documentStatus: 'awaiting_document'`; charges flip to paid only on a payment/verification transition.
- **O:** `issueShopInvoice()` lines 602-616 issue the invoice with `status = paid` whenever `netDue <= 0.005` (held earnings fully cover the fees) and, in the same transaction, flip every non-reversed billed charge to `Charge::STATUS_PAID`. In the baseline a fully-netted invoice is issued `unpaid` with `amountDue = 0.00` and its charges stay unpaid until a payment transition.
- **Files:** `backend/controllers/AdminFinanceController.php`
- **Verify — precondition P-RATES.** Trigger a threshold issuance for a shop whose held earnings fully cover its fees and read the invoice `status` and the charge statuses.

**F-FIT-15 · C-ADM-025 · deviation · built-not-working · The Transfer-requests tab badge always reads 0**
- **B:** the finance shell badges Transfer requests with the count of requested transfers.
- **O:** `_tabs.php:35` counts `TransferRequest::find()->where(['status' => STATUS_NEW])` — the `transfer_request` table — while `actionTransferRequests()` lists `Withdrawal` rows because (per its own comment, lines 639-643) `transfer_request` was never populated. **The badge is 0 regardless of how many settlement requests are queued.** The Shop Balances badge (`badgeOwingCount()`) and the Invoices badge read live data correctly.
- **Files:** `backend/views/admin-finance/_tabs.php`, `backend/controllers/AdminFinanceController.php`
- **Verify:** no precondition. Staging already has eight transfer requests; open the Finance shell and confirm the Transfer-requests badge is absent/0.

## Commercial config (4)

**CF-CC-03 · C-ADM-035 · gap · not-built · Saving the global config does not re-run threshold reconciliation**
- **B:** saving the global config applies the patch and then immediately re-runs threshold-invoice reconciliation, so lowering `carryForwardThreshold` issues threshold invoices for any shop now over the new limit as a direct consequence of the save.
- **O:** `CommercialConfigController::actionIndex()` (`:31-52`) saves, writes the two-field rate log and calls `refresh()`. No reconciliation is invoked. `reconcileBilling()` exists but runs only inside `actionShopBalances()` (`:116-120`), and `console/controllers/` contains no billing or reconciliation command. **A threshold change produces no invoices until someone happens to open that page — and, per CF-CC-01, the global value is not the one that page reads either.**
- **Files:** `backend/controllers/CommercialConfigController.php`, `backend/controllers/AdminFinanceController.php`
- **Verify — precondition: fix or bypass CF-CC-01 first, or the two mask each other.** Set a shop's carried balance above a lowered global threshold, save the Commercial Config form, then query `SELECT id, type, created_at FROM invoice WHERE shop_id = ? AND type = 'threshold'` **without loading any other admin page.** Confirmed if no invoice is created by the save itself. **Schedule with F-FIT-09.**

**CF-CC-04 · C-ADM-038 · deviation · n/a · Grace window is anchored on first portal login and derived live [common/]**
- **B:** `graceWindowEndsAt` is stamped at shop creation as `createdAt + config.graceWindowDays`; `isInGraceWindow(shop, now) = now <= shop.graceWindowEndsAt`. Inside it the marketing fee is forced to 0.
- **O:** `computeInGrace()` (`:1048-1055`) anchors on `shop.first_portal_login_at` and returns false when that column is unset, and the end is derived live from `graceWindowDays()` rather than stamped. Two differences: **a newly created shop whose owner has not yet logged into the portal is never in grace**, so a marketing fee the baseline waives to 0.00 is stamped at full; and because nothing is stamped, editing the global `grace_window_days` moves the grace window of every existing shop retroactively.
- **Files:** `common/components/FinanceLedgerService.php`
- **Verify — precondition P-RATES.** Create a shop, do not log into its portal, complete a navagoo-sourced booking, and read the marketing row. **Related to F-ADJ-05, which is the other half of the same two-anchors problem.**

**CF-CC-08 · C-ADM-035 · gap · not-built · Only two of ~24 config fields are audited on save**
- **B:** a config save records an audit event naming the changed keys.
- **O:** `logPpfChange()` (`:79-92`) writes `CommercialRateLog` rows only for the fields returned by `CommercialConfig::ppfAuditFields()`, which is `['processing_fee_pct','fixed_fee_sar']` (`:153-156`). Changes to the other ~22 fields — VAT %, marketing fee %, min marketing fee, carry-forward threshold, invoice due days, hold days, grace window, all eight messaging fields — are saved with **no audit record** of the old value, the new value or the actor beyond the row's own `updated_by` / `updated_at`.
- **Files:** `backend/controllers/CommercialConfigController.php`, `common/models/CommercialConfig.php`
- **Verify:** no precondition. Change VAT % and check `commercial_rate_log` and `audit_log`. **Part of the audit-event cluster — §7.6.**

**CF-CC-09 · C-ADM-039 · gap · built-not-configured · Five booking-workflow settings persist and render but have no consumer**
- **B:** `noShowGraceMin`, `onTimeStartGraceMin` (default 5), `onTimeFinishGraceMin` (default 10), `customerRescheduleLimit` (default 1), `customerRescheduleFullZoneOnly` (default true) and `packageCancellationsAllowed` (default false) are admin-editable settings governing no-show marking, KPI on-time bands, customer reschedule allowance and package cancellation.
- **O:** only `noShowGraceMin` has an enforcement path (`BookingTransitionService::canMarkNoShow()`). The other five persist as `booking_transition_config` rows and render on Settings ▸ Workflow, but a repo-wide search for each key returns only `BookingTransitionConfig.php`, `backend/controllers/SettingsController.php` and `backend/views/settings/workflow.php` — **no consumer.** The model's own comment (`:35-38`) records this: "they are NOT read by BookingTransitionService". Additionally `customerRescheduleFullZoneOnly` falls back to `false` when unset (`SettingsController:382`) where the baseline default is `true`, **so the stored value would be the permissive one even once a reader is wired.**
- **Files:** `common/models/BookingTransitionConfig.php`, `backend/controllers/SettingsController.php`, `backend/views/settings/workflow.php`
- **Verify:** no precondition. Set `customerRescheduleLimit` to 1 and attempt two customer reschedules on one booking. **Same shape as §7.1.**

## Catalogue & geography (4)

**F-CAT-06 · C-ADM-040 · deviation · built-not-working · A category can be saved with an empty name**
- **B:** category name is required — the mockup's form blocks save on an empty/whitespace name, and the inventory records "validation-blocked (missing EN/AR name)" as a state of `adm-catalogue-shop-category-form`.
- **O:** `ShopCategory::rules()` merges `[[['sort_order'],'integer'], [['sort_order'],'default','value'=>0]]` into the base rules with `array_replace_recursive`. Row 0 of the base rules is `[['name'],'required']`; the positional merge overwrites it with `[['sort_order'],'integer']`, so **the `name` required rule no longer exists** on the concrete model the controller instantiates. `name` keeps only the max-255 string rule. (The same merge also mangles row 1 into `[['sort_order','updated_by','sort_order'],'default','value'=>0]`, dropping the integer rules on `created_by`/`updated_by`.)
- **Files:** `common/models/ShopCategory.php`, `common/models/base/ShopCategory.php`
- **Verify:** no precondition. Submit the shop-category create form with the name field blank. Confirmed if the row saves with no validation error. **Derived by reading PHP, not executing it. Same construct as F-CAT-05 (S2) — §7.5.**

**F-CAT-07 · C-ADM-044 · deviation · built-not-working · Per-shop category image override is per-service, unclearable and discarded by unrelated edits**
- **B:** `setServiceCategoryImage(shopId, categoryId, imageUrl)` is an upsert-or-clear keyed by (shop, category) — never more than one override per pair, and passing no url clears the override.
- **O:** the override is stored on `service_category_assignment` with `UNIQUE(shop_id, category_id, service_id)` — **one row per service**, so a (shop, category) pair carries as many override rows as it has services. `ShopServiceController::actionUpdateCategoryImage` updates a single row selected by id, leaving sibling services on the old image, and returns early with "Please choose an image to upload" when no file is posted, **so there is no clear-the-override path back to the admin default.** The parallel columns on `shop_category_assignment` are read by `Shop::buildCategoryPayload` but written by nothing. Stored overrides are also discarded as a side effect of unrelated edits: `Shop::updateServiceCategoryAssignments()` deletes and re-creates assignment rows, and backend `ShopController::actionUpdate` deletes then re-inserts all `shop_category_assignment` rows on every shop save.
- **Files:** `frontend/controllers/ShopServiceController.php`, `common/models/Shop.php`, `common/models/ServiceCategoryAssignment.php`, `backend/controllers/ShopController.php`
- **Verify:** no precondition. Set a category image override for a shop, then save the shop from the admin form and re-check the override.

**F-CAT-08 · C-ADM-042 · gap · not-built · No category or city mutation writes an audit record**
- **B:** category delete emits `category.deleted`; `addCity` / `updateCity` / `deleteCity` each emit an audit event and `updateCity` records a field-level diff.
- **O:** no mutation in this area writes an audit record. `ShopCategoryController::actionCreate/actionUpdate/actionDelete` and `CityController::actionSaveGeo/actionDelete/actionToggleActive` set session flashes only; `BackendController` installs no audit behavior, and neither controller references `TimelineEvent`, `SystemLog` or any equivalent.
- **Files:** `backend/controllers/ShopCategoryController.php`, `backend/controllers/CityController.php`, `backend/controllers/BackendController.php`
- **Verify:** no precondition. **Part of the audit-event cluster — §7.6.**

**F-CAT-09 · C-ADM-082 · deviation · built-not-working · Deleting a city orphans its districts**
- **B:** districts belong to the city and disappear with it — the mockup stores them inline on the City record, so `deleteCity` removes them by construction.
- **O:** `CityController::actionDelete` calls `$model->deleteWithRelated()`; `City::relationNames()` lists only `'country'` (which is not even an ActiveQuery — `City::getCountry()` returns a plain `stdClass`), and `district.city_id` carries **no foreign key**. District rows for the deleted city are left pointing at a city id that no longer exists, and they remain selectable wherever districts are loaded by id rather than by city.
- **Files:** `backend/controllers/CityController.php`, `common/models/base/City.php`, `common/migrations/db/m231011_154315_add_city_district_table.php`
- **Verify:** no precondition. Delete a city with districts and `SELECT * FROM district WHERE city_id = <deleted id>`.

## System & RBAC (5)

**F-RBAC-06 · C-ADM-046 · gap · not-built · The permission catalogue is 25 menu keys, not the baseline's four-group scoped enum**
- **B:** a closed `PermissionKey` enum in four groups — Admin·Navigation (17, nav-flagged), Admin·Actions (5), Shop·Navigation (14, nav-flagged), Shop·Actions (8) — with an explicit nav-versus-in-page-action flag per entry.
- **O:** the catalogue is 25 `<controller>_<action>` menu keys in 13 feature sections, all implicitly platform-scope, **with no nav/action flag**; the view groups them by feature section and labels the single group heading "Platform · Actions" or "Shop · Actions" (`index.php:333-335`). Shop scope carries exactly one key — the `manageShop` umbrella appended in `UsersRolesController.php:84-90` — so none of the baseline's 14 shop nav / 8 shop action permissions is separately grantable, and **no shop-scope role can be composed below owner-equivalent.**
- **Files:** `backend/models/ManagerForm.php`, `backend/controllers/UsersRolesController.php`, `backend/views/users-roles/index.php`
- **Verify:** no precondition. **Schedule with F-RBAC-03/04/05 — one design decision covers the set.**

**F-RBAC-07 · C-ADM-050 · gap · not-built · Only the hiding primitive exists; there is no disabled-with-reason control**
- **B:** two distinct primitives — `Can` hides a region, `DisabledAction` renders the control but disables it with a per-permission explanatory reason.
- **O:** only the hiding primitive exists (nav `visible` flags). A permission-lacking request produces `HttpException` 403 from a controller `beforeAction` (e.g. `UsersRolesController:45-51`) or an AccessControl denial, with no in-place disabled control and no reason text; a repo-wide search for the baseline's reason strings returns no matches and no shared disabled-action helper exists in `backend/views`. The only reason-bearing lock found is the role-matrix checkbox revert-plus-toast at `index.php:543-547`, which keys off built-in-ness, not off the viewer's permissions.
- **Files:** `backend/views/layouts/menu/Menu.php`, `backend/controllers/UsersRolesController.php`, `backend/views/users-roles/index.php`
- **Verify:** no precondition. Request a permission-gated URL as a role lacking it and record the response shape.

**F-RBAC-08 · C-ADM-051 · gap · not-built · A custom role cannot be renamed**
- **B:** a custom role can be renamed (bilingual), and the rename refuses on a built-in role.
- **O:** no rename path exists — `UsersRolesController` exposes index, create-role, assign-role, toggle-permission and delete-role only, and the roles panel renders a Delete button for custom roles with no rename control (`index.php:355-361`). The role's human label is stored once as the RBAC item description at creation (`RoleForm::create:110`) and the machine name is slugged from it (`RoleForm::roleName:69-75`), **so a label typo is unfixable without deleting and recreating the role.** The label is also single-language.
- **Files:** `backend/controllers/UsersRolesController.php`, `backend/models/RoleForm.php`, `backend/views/users-roles/index.php`
- **Verify:** no precondition. Open the roles panel for a custom role and look for a rename control.

**F-RBAC-09 · C-ADM-051 / C-ADM-052 · gap · built-not-configured · No role- or user-management action writes an audit record**
- **B:** `role.created` / `role.renamed` / `role.deleted` / `role.permissions_changed` (carrying `{perm, on}`) are each emitted as audit events; `deactivateUser` / `reactivateUser` / `assignRole` each emit one.
- **O:** none does. `AuditLogService::emit` is implemented and registered (`common/config/base.php:219`) but has **zero call sites**, and `timeline_event` is written only by `AddToTimelineCommand` (signup/shop/request flows). `actionCreateRole`, `actionTogglePermission`, `actionDeleteRole` and `actionAssignRole` write nothing; `UserController::actionToggleStatus` writes a `UserStatusLogs` row, which records the status change but is not part of the audit-event taxonomy the Events screen reads.
- **Files:** `backend/controllers/UsersRolesController.php`, `backend/controllers/UserController.php`, `common/components/AuditLogService.php`
- **Verify:** no precondition. **Part of the audit-event cluster — §7.6.**

**F-RBAC-11 · C-ADM-048 · deviation · n/a · Nav gating is an OR-list behind an `administrator` wildcard, and the section headers are themselves gated**
- **B:** each nav entry carries exactly one required permission and the sidebar is a strict membership filter — no implicit grants, no wildcards; sections are positional groupings of the filtered list.
- **O:** nav gating is an OR of several permission keys preceded by a blanket `can('administrator')` bypass on nearly every entry (`Menu.php:42, :49, :58, :73-83, :106, :114, :122, :130, :143, :165, :195`) — **Finance alone ORs eight keys.** The `administrator` term is an implicit wildcard. The four section **headers** are themselves gated on `can('administrator')` (`:33, :63, :135, :170`), so a manager sees an ungrouped flat list rather than the baseline's five groups. Home (`:17-22`) carries no `visible` key at all and renders for every authenticated backend session. Settings renders as the last item inside the System group rather than in a separate sidebar footer slot.
- **Files:** `backend/views/layouts/menu/Menu.php`, `backend/views/layouts/_tw_admin_sidebar.php`
- **Verify:** no precondition. Sign in as a `manager` and record the rendered nav plus headers. **This is the qualification attached to the "Navigation IA is at full parity" statement in `REPORT_ADMIN.md` §6 — full grouping parity holds for an administrator session only.**

## Notifications (2)

**NOTIF-05 · C-ADM-058 · deviation · built-not-working · Reminders scheduled more than 7 days ahead fire late [common/]**
- **B:** the due-notification pass evaluates every non-terminal booking and fires a trigger when `dueAt <= now` and `dueAt >= the booking's creation moment`. Nothing bounds how far ahead of the appointment a reminder may be scheduled.
- **O:** `console/controllers/NotificationsController.php` restricts the scan to bookings whose `booking_date` falls between today and today + `SCAN_DAYS_AHEAD` (7). A trigger whose due instant is more than 7 days before the appointment is not evaluated at that instant; the booking only enters the scan window 7 days out, and `shouldFireAt()` is then still satisfied, **so the reminder fires late rather than not at all.** `NotificationTrigger::rules()` puts no upper bound on `days_before` (min 0 only), so the admin form can author such a trigger. **No live impact on the seeded 24h / 3h reminders.**
- **Files:** `console/controllers/NotificationsController.php`, `common/models/NotificationTrigger.php`
- **Verify:** no financial precondition. Author a trigger with `days_before` > 7 and a booking further out, then run the pass on the day the reminder is due.

**NOTIF-06 · C-ADM-057 · deviation · built-not-working · The offset field is labelled "Minutes before" but requires a negative value**
- **B:** offset mode stores `offsetMinutesBefore`, a **positive** count of minutes before the appointment; `due = appointmentStart − offsetMinutesBefore`.
- **O:** the column stores a **signed** offset added to the appointment (negative = before). The form labels the field "Minutes before" while requiring a negative value, disclosing the convention only in a hint line ("Negative = before the event. –180 = 3 hours before."). **A positive entry silently schedules the reminder after the appointment**, and `timingLabelLocalized()` renders `abs(minutes)` so the catalogue row still reads "{N} min before appointment" for it. The due-instant math itself is correct in both modes.
- **Files:** `backend/views/notification-trigger/_form.php`, `common/models/NotificationTrigger.php`, `common/components/NotificationDispatchService.php`
- **Verify:** no precondition. Enter a positive value in "Minutes before", save, and read back the catalogue row's timing label against the stored column.

## Adjacent admin (3)

**F-ADJ-03 · C-ADM-075 · deviation · built-not-working · Shop-created events record the shop's id in the actor column**
- **B:** an audit event carries a resolved actor and a typed target `{type, id}` from a closed `EntityType` vocabulary.
- **O:** `Shop::notifyNewShop` (`base/Shop.php:708-720`) and `Branch::notifyNewShop` (`base/Branch.php:220-232`) pass `'user_id' => $this->id`, i.e. **the shop's primary key**, into both the event's actor column and its data payload. `TimelineEventController::decorate` (`:229-252`) resolves `user_id` against the User table for the Actor cell and derives the target type from the payload keys — the payload carries `user_id` and no `shop_id`, so a shop-created event renders with target type `user` and, **if a User row happens to share that id, with an unrelated person's name in the Actor column.** The admin who actually created the shop is never recorded.
- **Files:** `common/models/base/Shop.php`, `common/models/base/Branch.php`, `backend/controllers/TimelineEventController.php`
- **Verify:** no precondition. Create a shop whose id collides with an existing user id and read the Events screen row.

**F-ADJ-05 · C-ADM-079 · deviation · built-not-working · The stamped grace window uses a hardcoded 14 days, not the admin-editable value**
- **B:** on creation a shop is stamped `graceWindowEndsAt = now + config.graceWindowDays` — the window length is the admin-editable global commercial-config value.
- **O:** `ShopController` stamps the window from a hard-coded class constant `const GRACE_WINDOW_DAYS = 14` (`:1443`), used at `:1458` on create and again at `:1083` when activation opens a fresh window. `commercial_config.grace_window_days` is a real admin-editable field and **is** read by `FinanceLedgerService::graceWindowDays()` (`:1061-1068`) for the fee-waiver decision, **so changing it in admin moves the fee-waiver window but leaves every shop's stamped `grace_window_ends_at` on a 14-day clock.** The two grace notions also use different anchors — the stamped column runs from creation/activation, `computeInGrace()` (`:1048-1055`) from `shop.first_portal_login_at`.
- **Files:** `backend/controllers/ShopController.php`, `common/components/FinanceLedgerService.php`, `common/models/CommercialConfig.php`
- **Verify:** no financial precondition for the **stamp**. Set `commercial_config.grace_window_days` to a distinctive value (e.g. 3), save, create a shop through the admin form, and read `shop.grace_window_ends_at`. Confirmed if it lands 14 days out, not 3. **Pairs with CF-CC-04 — same two-anchors problem.**

**F-ADJ-06 · C-ADM-079 · gap · not-built · A new shop is stamped no cancellation policy, so every cancellation falls to NO_REFUND**
- **B:** on creation a shop is stamped cancellation-policy defaults `cancelFullHours 48` / `cancelPartialHours 24` / `partialRefundPct 50`.
- **O:** `actionCreate` and `seedShopProvisioning` stamp no cancellation-policy values, and `refund_period_start` / `refund_period_end` / `partial_refund_value` are absent from the admin create and update form (`_form.php`). Both period columns are nullable strings with no code or column default. `refundZoneFor` (`:516-525`) only returns FULL_REFUND or PARTIAL_REFUND when the corresponding threshold is non-null, **so a freshly created shop falls through to NO_REFUND for every customer cancellation until its owner sets a policy in the shop portal.** The shop does prefill a bilingual `cancel_terms` narrative at creation (`:224-239`) whose placeholders describe hour thresholds that no field has yet been populated with.
- **Files:** `backend/controllers/ShopController.php`, `backend/views/shop/_form.php`, `common/components/FinanceLedgerService.php`
- **Verify:** no financial precondition. Create a shop through the admin form and read `shop.refund_period_start`, `refund_period_end`, `partial_refund_value` (all expected null). **Same probe as F-ADJ-05; it also settles the one item this audit could not read from source — whether `open_at`, `close_at` and `slot_time_step` carry DB-level creation defaults, which is currently unresolved rather than reported.**

---

# S4 — Minor

9 items.

**SE-17 · C-ADM-008 · deviation · built-not-configured · The locked-feature page never names the tier needed [common/]**
`minTierFor()` is implemented correctly but has no caller. `EntitlementFilter::renderLocked()` passes only `reason` and the current plan name to `frontend/views/site/_locked.php`, which renders "This feature isn't included in your current plan" plus "Current plan: X" and a "View plans" button. The baseline's "Available on GROWTH / PRO" copy never appears.
Files: `frontend/components/EntitlementFilter.php`, `frontend/views/site/_locked.php`, `common/components/EntitlementService.php` · **Verify:** no precondition — open a locked surface and read the copy.

**FIN-LEDGER-06 · C-ADM-020 · deviation · built-not-configured · A redemption visit raises a marketing fee on the per-session price [common/]**
Baseline: a redemption visit raises zero booking charges — all Navagoo money was taken at the package sale. Observed: `buildPackageRedemptionCharges()` stamps a marketing fee on the entitlement's `per_session_price` for every navagoo-sourced redemption, alongside the fee already taken at purchase on the full package price. **The code documents this as a deliberate design decision (NVG-BEA-008 W4), so it is an intentional divergence rather than an oversight** — recorded so the baseline difference is visible rather than assumed resolved.
Files: `common/components/FinanceLedgerService.php` · **Verify — precondition P-RATES.** Redeem a package session and read the resulting charge. **Product decision, not a defect to fix by default.**

**FIN-LEDGER-10 · C-ADM-018 · deviation · built-not-configured · Notification-fee rows stamp the unit price as the basis, not the message count [common/]**
Baseline: `basisAmount = count`, `ratePct = 0`, `fixedAmount = unit`. Observed: `billPaidSend()` stamps `base_amount = unit` — the same value as `total_amount` and `fixed_snapshot`. The count is preserved in `meta.count` and read back by `markNotifFailed()`, **so the refund math is unaffected**; the divergence is confined to what the ledger's basis column displays for sms/whatsapp rows.
Files: `common/components/NotificationDispatchService.php` · **Verify — preconditions P-SMSPRICE, P-NOTIFCHG.** Read a notification charge's `base_amount` against `meta.count`.

**F-FIT-16 · C-ADM-026 · deviation · n/a · Settlement mismatch tolerance is 1.00 SAR, not 0.01**
`backend/views/withdrawal/_settlement_form.php:303` uses `if (diff > 1)` — one riyal — as an inline literal in the client-side watcher, where the baseline surfaces the mismatch warning above 0.01 with the tolerance as a named constant. **A discrepancy of up to 1.00 SAR settles with no warning shown.**
Files: `backend/views/withdrawal/_settlement_form.php` · **Verify:** no precondition. Enter an amount paid 0.50 off the computed payout and confirm no warning appears.

**F-FIT-17 · C-ADM-027 · deviation · n/a · `document_status` carries two meanings on one column, and `net_from_payout` has no constant**
Baseline: `DocumentStatus ∈ {awaiting_document, uploaded}`; `attachInvoiceDocument(id, url)` sets `uploaded`. `PaidMethod` includes `net_from_payout`. Observed: `Invoice::document_status` is a 3-value integer gate {0 none, 1 pending_verification, 2 verified} carrying two different meanings — `AdminInvoiceController::actionUploadDocument()` sets `DOC_VERIFIED` when the admin attaches the invoice PDF, while `EarningsController::actionPayInvoice()` sets `DOC_PENDING_VERIFICATION` for the shop's bank slip. `PaidMethod` is {bank_transfer, card, cash}; `net_from_payout` has no constant — settlement invoices are written `status = paid` with `payment_method` left null.
Files: `common/models/base/Invoice.php`, `backend/controllers/AdminInvoiceController.php`, `frontend/controllers/EarningsController.php` · **Verify:** no precondition — read the two write paths and the constant list.

**F-CAT-10 · C-ADM-040 · deviation · built-not-working · "Add category" from a shop-category row does not pre-scope the form**
`catalogue.php:149` links to `['/shop-category/create', 'parent_id' => $parent->id]`, but `actionCreate` never reads the GET parameter and `_form.php` renders the select from `$model->parent_id`, which is null for a new record. The form opens with "— None (top-level) —" selected, so the admin must re-pick the parent manually — **and cannot pick it at all when it already has a child (F-CAT-01).**
Files: `backend/views/shop-category/catalogue.php`, `backend/controllers/ShopCategoryController.php` · **Verify:** no precondition. Click Add category on a shop-category row and read the parent select. **Fix alongside F-CAT-01.**

**F-RBAC-10 · C-ADM-049 · deviation · not-built · A shop-scope credential gets a 403 or a login error, not a bounce to the shop portal**
Baseline check (2): a session whose role scope is not platform reaching the admin portal is redirected to the shop portal before any admin content renders. Observed: there is no layout- or request-level scope guard in the admin tier. The equivalent block happens once, at authentication — `LoginForm::getUser:90-93` adds a validation error for any `user_type` outside customer/manager, and `LoginForm::login:124-127` logs the just-created session out and throws `ForbiddenHttpException` when `loginToBackend` is absent. Separately, `shopOwner` **is** listed in the backend catch-all allow rule (`web.php:133-136`), so the request-time rules do not themselves exclude shop-scope sessions — **the exclusion rests solely on the login-time check.**
Files: `backend/config/web.php`, `backend/models/LoginForm.php`, `common/behaviors/GlobalAccessBehavior.php` · **Verify:** no precondition. (a) Request `/users-roles/index` with no session — expect a redirect to `/sign-in/login`. (b) Attempt a backend sign-in with a shop-owner credential — expect the login-form error "Your account don't have permission to login here" or a `ForbiddenHttpException` page; in neither case a redirect to the shop portal.

**F-RBAC-12 · C-ADM-051 · deviation · n/a · A custom role cannot be created empty, and its machine name is prefixed `shop_` while it behaves as platform-scope**
Baseline: `createRole` creates a custom role with an **empty** permission set, and the modal carries a starts-with-no-permissions note plus a bilingual label. Observed: `RoleForm::rules:29` makes `permissions` required with the message "Pick at least one permission.", so a role cannot be created empty and then composed in the matrix. The label is a single free-text field (no Arabic counterpart), and the generated machine name is always prefixed `shop_` (`RoleForm::roleName:74`) **even though the resulting role is classified and displayed under the Platform scope group** (`UsersRolesController.php:138`) — the prefix reads as a shop-scope marker while the role behaves as a platform one.
Files: `backend/models/RoleForm.php`, `backend/controllers/UsersRolesController.php`, `backend/views/users-roles/index.php` · **Verify:** no precondition. Attempt to create a role with no permissions ticked, then read the created role's machine name and its displayed scope group.

**F-ADJ-07 · C-ADM-082 · gap · not-built · City mutations emit no audit event**
`CityController` emits nothing: `actionSaveGeo` (`:110-193`, the create-and-update path behind the aurora city modal), `actionCreate` (`:230`), `actionUpdate` (`:265`), `actionDelete` (`:302`) and `actionToggleActive` (`:86`) all mutate and return without writing a `timeline_event`, an `audit_log` row, or any diff. City name changes, district additions and removals, activation toggles and outright city deletion leave no record on the Events screen.
Files: `backend/controllers/CityController.php`, `common/models/City.php`, `common/models/base/City.php` · **Verify:** no precondition. **Part of the audit-event cluster — §7.6.**

---

# Outside the area scorecard

Seven items from `_hypotheses.md` and `_hypotheses_h1_h2.md`. They are real, evidenced findings;
they are not counted in the report's per-area scorecard, which indexes the eight contract areas
only. **Three of them (H5.2, H5.3, H7) carry no severity in the source and none is assigned here.**

## UI findings from the H1 / H2 probes (4)

**F-ADM-H1-01 · S3 · deviation · The IBAN tile on shop edit spills out of its card at 1024 and 1280**
- **Surface:** `/shop/update?id=20` → "Shop Overview" card → *IBAN NUMBER* tile. Clean at 1440 and 1920.
- **Observed:** the value is `SA22000000000000000000000000000000` — 34 characters, one unbroken token. Computed style on the value element is `word-break: normal; overflow-wrap: normal; text-overflow: clip; overflow-x: visible`, so it cannot wrap and is not ellipsised. Measured spill out of the value box: **133 px at 1024, 48 px at 1280, 0 at 1440 and 1920**; spill past the tile's own border 116 px / 31 px / −17 px. At 1024 the rendered right edge is **1073 vs a 1024 viewport → 49 px off-screen, string cut mid-value** — and because the page has no horizontal scroll (`scrollWidth === clientWidth`), that 49 px is unreachable. **The IBAN cannot be read in full at 1024.** The overflow propagating up the ancestor chain (`div.tw-shop-form`, `form#shop-form`, `div.space-y-6`, `div.ng-page`, `div#tw-content-pjax`, all reporting 96 px at 1024 / 11 px at 1280) is a measurement consequence of the single tile, not an independent defect.
- **Baseline note:** the mockup has no equivalent read-only IBAN tile on this surface; its shop edit exposes IBAN as an editable input inside a 672 px modal. **There is no baseline behaviour to match — this is an implementation-side layout defect rather than a divergence from a mockup rule.**
- **Evidence:** `02_EVIDENCE/admin/shop-edit/shop-overview-spill@{1024,1280,1440}-staging.png`, `shop-edit@{1024,1280,1440,1920}-staging.png`
- **Verify:** no precondition. Load `/shop/update` for a shop with a full-length IBAN at 1024 and 1280 and re-measure the value element's `scrollWidth` against its `clientWidth`.

**F-ADM-H2-01 · S3 · candidate gap · Commercial name is single-language with no translate affordance**
- **Observed:** the portal exposes a single `Shop[commercial_name]` text input and a single `Shop[title]`. Probe results: `translateButtons: 0`, `arabicNamedInputs: []` — no input in `form#shop-form` has an `_ar`/`arabic` name, and no control offers a translate action.
- **Baseline:** the mockup pairs an ENGLISH and an العربية input under one "Commercial name `*`" label, each with its own translate button (`translateButtons: 2`) and the helper "fill one — the other auto-translates".
- **Note carried from the source:** this is the **only** baseline field with no portal counterpart — every other mockup field maps to an existing portal control. **Given the project's mandatory Arabic/English bilingual requirement, a reviewer may rate this higher than S3;** it is recorded at S3 because it is a field-level gap rather than a dead-ended flow.
- **Files/Evidence:** `backend/views/shop/_form.php`; `02_EVIDENCE/admin/shop-edit/shop-edit@1440-mockup.png`, `shop-edit-modal@1440-mockup.png`, `shop-edit@1440-staging.png`
- **Verify:** no precondition. Count translate buttons and `_ar`-named inputs in `form#shop-form`.

**F-ADM-H2-02 · S4 · label divergence on shop edit**
- **Observed:** matched fields use different wording on each side — Bank / Bank name; Owner mobile / Shop owner mobile (for login & OTP); Marketing fee % / Marketing Fee Rate %; Deposit % cap / Maximum Deposit % Override; Credit limit / Credit Limit (Carry-forward Threshold); Settlement hold (days) / Minimum Elapsed Period (Days); VAT registered / Is the shop VAT-registered?. Section headings differ too: `BASIC INFO` / `STORE DETAILS` / `SETTINGS & TRANSFER THRESHOLDS` versus `Basic Information` / `Store Details` / `Shop Settings` + `Transfer Settings` — the baseline's third section is split in two on the portal, with the VAT toggle promoted to its own section.
- **Files/Evidence:** `backend/views/shop/_form.php`; as F-ADM-H2-01 · **Verify:** no precondition.
- **Note:** "Marketing Fee Rate %" is also the label on the inert column in **F-ADJ-04 (S2)** — a rename here should not be made independently of that decision.

**F-ADM-H2-03 · S4 · missing state · No field on the portal shop form is marked required**
- **Observed:** `requiredAttr: 0`, `labelsWithAsterisk: 0` on `/shop/update?id=20` @ 1440.
- **Baseline:** the mockup marks Commercial name with `*` (1 asterisk; also `required` attr 0, so the marking is visual only).
- **Files/Evidence:** `backend/views/shop/_form.php`; as F-ADM-H2-01 · **Verify:** no precondition.

## Hypothesis outcomes carried as work items (3)

**H5.2 · severity not assigned in source · built, not configured · The plan feature matrix is 0 of 57 populated**
- **Observed:** on `https://stageadmin.navagoo.com/shop/plans`, `document.querySelectorAll('table input[type=checkbox]')` returns **total 57, checked 0, unchecked 57** (19 features × 3 plans). Every feature row reads `- - -` across Starter / Growth / Pro. Measured directly rather than inferred from rendering.
- **Consequence:** combined with H5.3, **the admin feature matrix does not currently influence entitlement at all** — plans resolve to tier defaults regardless of what the matrix shows.
- **Verify:** re-run the same DOM count. **This is a configuration action, not a code change — and it is a precondition for verifying SE-01 and SE-14.**

**H5.3 · severity not assigned in source · deviation · built, not working as an editor→engine link · The admin matrix stores display labels; the entitlement catalogue expects canonical keys**
- **Observed:** `shopFeatures()` carries a note that live plan rows stamp display labels ("Online booking & real-time calendar") rather than canonical keys (`online_booking`). Honouring them verbatim would fail every `hasFeature()` lookup and lock out a paying shop, so the code intersects stamped values against the canonical catalogue and **falls back to the tier's default feature set when none match.**
- **Files:** `common/components/EntitlementService.php`, `backend/controllers/ShopController.php`
- **Verify:** no precondition. Stamp a display label on a plan and confirm `shopFeatures()` falls back to the tier default rather than honouring it. **Schedule with SE-14 — the same fallback masks both.**

**H7 · severity not assigned in source · gap · not built · The notification-trigger dispatch path reaches no provider [common/]**
- **Observed:** `common/components/NotificationDispatchService.php` documents its own position at lines 38-47 — paid-channel sends consume a free monthly allowance, sends beyond it append an append-only `Charge` row (type `sms`|`whatsapp`, unit sell price + VAT, `net_from_settlement`) linked to the send via `charge.meta.notification_id`, and a failed delivery is refunded by the admin-configured per-message amount via `markNotifFailed()`. The same comment block states: **"The actual provider send (Msegat/Twilio) is still a separate integration — no gateway is wired yet."** `SMSHelper` and `WhatsAppHelper` exist and are referenced from `frontend/controllers/BookingController.php`, `frontend/modules/user/controllers/SignInController.php`, `backend/controllers/CustomerInvitationCampaignController.php` and several `api/` controllers, so provider-calling code exists elsewhere — **it is the notification-trigger dispatch path specifically that does not reach a provider.**
- **Classification:** dispatch/metering — built. Provider gateway on the trigger path — not built.
- **Verify — preconditions P-SMSPRICE, P-NOTIFCHG.** Enable a paid channel on a staging shop and fire the trigger. **See the open question below before treating this as a low-priority integration task.**

---

# UI parity findings (Track U)

18 items from `03_FINDINGS/admin/_ui-parity.md`, summarised in `REPORT_ADMIN.md` §5. **Kept
separate from the behavioural items above** so the two dimensions stay distinguishable — spec §4.3
scores them on separate tracks and forbids blending them. **0 S2 · 8 S3 · 10 S4. No S1.**

**How these were graded.** Twelve areas, one primary screen per area, both sides at 1440 CSS-px
LTR, plus `shops` sampled in Arabic/RTL on both sides. Baseline: the mockup at `localhost:6100`
(super_admin session). Implementation: `stageadmin.navagoo.com` (admin session). Grading is at
design-system fidelity — a different DOM reaching the same rendered result **passes**, pixel offsets
are not defects, and portal-only columns, filters, pagination and actions are recorded as *richer
data*, not as findings. **No baseline screen was absent.** Design tokens match on every value
sampled; the only token-level divergence is F-ADM-UI-10.

**Coverage limit — carry this wherever these items are quoted.** UI parity is established for
**primary landing surfaces only**. All 38 modals, the 1 drawer, the 14 panels, every non-landing tab
interior, permission-denied variants and all but two empty states were **not compared** — no claim
is made about them in either direction. Only 1440 was captured in this pass.

**Files.** The comparison was browser-side on both sides, so the source records evidence paths and
DOM measurements rather than portal source files. Where an item names a file or selector, it is
given; where it does not, the item says so rather than guessing.

**Verify (applies to every item below).** No data precondition — these are rendering comparisons.
Load the named portal route and the mockup counterpart at 1440 and re-take the stated measurement or
string. Five items could not be fully exercised on staging data and are marked *not comparable* in
the source rather than counted as gaps: catalogue's second tree level, the shops branch badge, the
subscriptions feature-matrix ordering, the notifications inline Approve action, and F-ADM-UI-18.

---

## S3 — Moderate (8)

**F-ADM-UI-01 · S3 · deviation · shops · subscriptions · Information architecture · One shared six-segment bar spans two baseline screens**
- **Baseline:** the Shops screen has a two-segment bar, `Shops (n)` / `Requests (n)`. The four subscription views (Plans, Offers, Dashboard, Payment methods) are mounted only by the Subscription Plans screen, which has its own four-segment bar. **The baseline source records that these views were deliberately moved out of Shops.**
- **Observed:** both screens share one six-segment bar — `Shops (20)`, `Requests (0)`, `Subscription Plans`, `Offers`, `Subscription Dashboard`, `Payment methods`. The four subscription segments are reachable from, and rendered on, the Shops screen.
- **Evidence:** `02_EVIDENCE/admin/shops/shops-main@1440-{mockup,staging}.png`, `02_EVIDENCE/admin/subscriptions/subscriptions-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source (browser-side comparison).

**F-ADM-UI-02 · S3 · deviation · analytics-costs · Information architecture · The P&L Trends panel renders 1 of 3 baseline charts**
- **Baseline:** the Trends panel below the P&L statement renders three charts — a revenue/COGS/margin composed chart, a `GMV & bookings` chart, and a stacked `Revenue by line` area.
- **Observed:** one chart renders — `Revenue, COGS & gross margin`. `GMV & bookings` and `Revenue by line` are **absent from the DOM** (the `h3` list contains only `P&L statement` and `Revenue, COGS & gross margin`; one chart element on the page). **Both missing charts do exist on the portal's Dashboard**, so this is specific to the P&L screen.
- **Evidence:** `02_EVIDENCE/admin/analytics-costs/analytics-costs-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source. **Schedule with F-ADM-UI-03 — same screen, and both patterns already exist on the portal's own Dashboard.**

**F-ADM-UI-03 · S3 · deviation · analytics-costs · Component vocabulary · No refresh controls on the P&L screen**
- **Baseline:** the P&L screen pairs the period segmented control with refresh controls — a `Refresh` button and an "Updated … · auto every N min" caption — the same pattern the Dashboard uses.
- **Observed:** the period segmented control is present with all five options; no `Refresh` control and no updated-at caption exist on the page. **The portal's Dashboard does carry both**, so the pattern exists in the implementation but is not applied here.
- **Evidence:** `02_EVIDENCE/admin/analytics-costs/analytics-costs-main@1440-staging.png`, `02_EVIDENCE/admin/dashboard/dashboard-main@1440-staging.png`
- **Files:** not identified in the source.

**F-ADM-UI-04 · S3 · deviation · system · Information architecture · The Events surface filter uses application tiers, not product surfaces**
- **Baseline:** the Events surface filter offers `All`, `admin`, `shop`, `customer`, `specialist`, `system` — six options describing which product surface raised the event.
- **Observed:** the filter offers `All`, `backend`, `frontend` — three options describing which application tier raised it. **Row values follow the same taxonomy** (`backend` / `frontend` chips in the Surface column), so this is a vocabulary change rather than a data gap.
- **Evidence:** `02_EVIDENCE/admin/system/system-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source. **Related context:** the Events screen reads `timeline_event`; see F-ADJ-01 and `REPORT_ADMIN.md` §7.6 for what does and does not write to it.

**F-ADM-UI-05 · S3 · deviation · system · Information architecture · No shop combobox on Events**
- **Baseline:** Events offers four comboboxes — action, actor, **shop**, target.
- **Observed:** three comboboxes are present (`action`, `actor`, `target`). No shop combobox exists; **the audit log cannot be narrowed to one shop.**
- **Evidence:** `02_EVIDENCE/admin/system/system-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source.

**F-ADM-UI-06 · S3 · deviation · home · Information architecture · `Recent Activity` has no view-all affordance**
- **Baseline:** the `Recent activity` panel ends with a `View events log` button that deep-links to the Events screen.
- **Observed:** the `Recent Activity` card renders its heading, subtitle and event list, then closes — there is no view-all link or button in the card markup. **The panel is a dead end.**
- **Evidence:** `02_EVIDENCE/admin/home/home-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source.

**F-ADM-UI-07 · S3 · deviation · finance (also system/events) · Responsive integrity · The transfer table needs horizontal scroll at 1440, putting four columns and the row action out of view**
- **Baseline:** the twelve-column transfer table fits the 1440 content column; `Net Payout`, `Amount Paid`, `Status` and the `Review` action are all visible without scrolling.
- **Observed:** the table measures `scrollWidth` **1546px inside a 1136px container** and gains a horizontal scrollbar. At 1440 the visible cut is at `Fee VAT`, so **`Net Payout`, `Amount Paid`, `Status` and the row's primary `Review` / `View` action are all off-screen** until the user scrolls the table sideways. The Events table behaves the same way, clipping `Message`.
- **Contributing factor (carry this):** staging shop names are bilingual (`بيوتي سنتر | Beauty Center`) and consume more width than the baseline's single-script names, **so part of this is data-driven** rather than purely a layout choice.
- **Evidence:** `02_EVIDENCE/admin/finance/finance-main@1440-{mockup,staging}.png`, `02_EVIDENCE/admin/system/system-main@1440-staging.png`
- **Files:** not identified in the source. **Related:** the same responsive-table pattern was measured as *not* a defect on `/shop/index` and `/admin-finance/transfer-requests` at 1024 in the H1 probe (`REPORT_ADMIN.md` §8) — the finding here is the column set that falls out of view at 1440, not the wrapper.

**F-ADM-UI-08 · S3 · deviation · shell (all areas) · Component vocabulary · Sidebar section headers do not collapse**
- **Baseline:** sidebar section headers (`Operations`, `Money`, `Growth`, `System`) are buttons with a chevron and collapse their group.
- **Observed:** section headers render as static uppercase labels. **The portal ships the CSS for a `<details>`-based collapsible group** (`.tw-sec-sum`, `.tw-sec-chev`, including an RTL chevron rule) **but no `<details>` element exists in the rendered sidebar** (`document.querySelectorAll('details').length === 0`), so the groups cannot be collapsed. The whole-rail collapse toggle in the footer does work on both sides.
- **Evidence:** `02_EVIDENCE/admin/dashboard/dashboard-main@1440-{mockup,staging}.png`
- **Files:** the source names the CSS classes `.tw-sec-sum` / `.tw-sec-chev` only. **Related, not the same finding:** F-RBAC-11 records that the four section headers are themselves gated on `can('administrator')` (`Menu.php:33, :63, :135, :170`).

---

## S4 — Minor (10)

**F-ADM-UI-09 · S4 · deviation · analytics-costs · Design tokens · The P&L screen renders the literal string `SAR`**
- **Baseline:** currency renders with the riyal glyph on every money value across every admin screen.
- **Observed:** the P&L screen renders `SAR` before every amount (`SAR 2,652.89`, `SAR 6,522.23`, `−SAR 3,869.34`) while the portal's own Home, Dashboard, Bookings and Finance screens use the glyph. **The inconsistency is internal to the portal as well as a divergence from the baseline.**
- **Evidence:** `02_EVIDENCE/admin/analytics-costs/analytics-costs-main@1440-staging.png`, `02_EVIDENCE/admin/home/home-main@1440-staging.png`
- **Files:** not identified in the source.

**F-ADM-UI-10 · S4 · deviation · all areas · Design tokens · Primary CTA corner radius**
- **Baseline:** primary CTAs (`Add shop`, `New plan`, `New promotion`) are fully rounded pills.
- **Observed:** the same buttons carry `border-radius: 14px`. Fill (`rgb(89, 66, 121)`), text colour, font size and weight are identical; **only the corner radius differs.** This is the **only** token-level divergence found in the whole token comparison.
- **Evidence:** `02_EVIDENCE/admin/shops/shops-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source; the change is in the Tailwind bundle, so **re-run `npm run build:css` after any class edit.**

**F-ADM-UI-11 · S4 · deviation · home · Label / copy · KPI captions and panel titles use title case and shortened forms**
- **Baseline:** sentence case — `Today's bookings`, `Settlement liability`, `Outstanding receivables`, `Top shops this week`, `Platform week` with the subtitle `bookings per day · last 7 days`, and `Recent activity` with the subtitle `latest admin-surface events`.
- **Observed:** title case and shortened forms — `Today Bookings`, `Settlement Liability`, `Outstanding Receivables`, `Top Shops`, `Platform Week` with the subtitle `Last 7 days bookings`, and `Recent Activity` with the subtitle `Platform live feed`. **Panel order, count and content are unchanged.**
- **Evidence:** `02_EVIDENCE/admin/home/home-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source. **Bilingual requirement applies to any copy change — both `ar/` and `en/` message files.**

**F-ADM-UI-12 · S4 · deviation · home · Responsive integrity · Quick actions lay out 2×2 and one label truncates at 1440**
- **Baseline:** the four quick-action buttons sit in one row and show their full labels (`Add shop`, `Verify payments`, `Settle transfers`, `Events log`).
- **Observed:** the `Quick Actions` card lays the four buttons out as a **2×2 grid** inside the right-hand column and the second label truncates to `Verify payme…` at 1440.
- **Evidence:** `02_EVIDENCE/admin/home/home-main@1440-staging.png`
- **Files:** not identified in the source.

**F-ADM-UI-13 · S4 · deviation · shops (RTL sample) · RTL / Arabic · Numeral systems are mixed within one screen**
- **Baseline:** in Arabic the mockup uses Arabic-Indic digits consistently — segment counts render as `المتاجر (١٥)` / `الطلبات (١)` and the marketing rate as `%٥`.
- **Observed:** the portal mixes numeral systems within one screen: segment counts stay Latin (`المتاجر (20)`, `الطلبات (0)`) while the pagination footer converts (`عرض ١-١٧ من أصل ٢٨ مُدخل`). **The header clock mixes them inside a single string** — `٦ أغسطس ٢٠٢٦ · 3:54:35 AM`.
- **Evidence:** `02_EVIDENCE/admin/shops/shops-main@1440rtl-{mockup,staging}.png`
- **Files:** not identified in the source.

**F-ADM-UI-14 · S4 · deviation · shops (RTL sample) · Label / copy · Two Arabic shop-type badges and the deep-link word differ**
- **Baseline:** Arabic shop-type badges read `إناث` / `ذكور` / `للجنسين`; the deep link reads `فتح`.
- **Observed:** type badges read `أنثى` / `ذكر` / `للجنسين`; the deep link reads `مفتوح`. **All other Arabic strings on the screen — nav, headers, `h1`, subtitle, VAT badge — match the baseline verbatim.**
- **Evidence:** `02_EVIDENCE/admin/shops/shops-main@1440rtl-{mockup,staging}.png`
- **Files:** not identified in the source; the strings live in the `ar/` message files. **Bilingual requirement applies.**

**F-ADM-UI-15 · S4 · deviation · system · Component vocabulary · The inline role select is platform-scope only and carries no grouping**
- **Baseline:** the user table's Role cell is an inline `<select>` whose options are grouped platform / shop, or a read-only badge when the viewer lacks permission.
- **Observed:** the inline select renders **for platform-scope users only** and carries no `<optgroup>` grouping. Shop-scope rows render the role as plain text with no control, **so their role is not changeable from this table.**
- **Evidence:** `02_EVIDENCE/admin/system/system-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source. **Related, not closed by this item:** the permission-denied variant of this cell was not exercised (only the admin session was used), and F-RBAC-04 records that four staff roles exist where the baseline defines seven.

**F-ADM-UI-16 · S4 · deviation · notifications · Information architecture · Channel chip order**
- **Baseline:** the `Channels & template status` cell lists channels in-app → SMS → WhatsApp.
- **Observed:** the cell lists SMS → WhatsApp → in-app. **Chip styling and the Free / Approved status labels are otherwise identical.**
- **Evidence:** `02_EVIDENCE/admin/notifications/notifications-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source.

**F-ADM-UI-17 · S4 · deviation · analytics-costs · Label / copy · The Costs rows render without the `Cost register` card heading**
- **Baseline:** the Costs screen wraps its rows in a card titled `Cost register`.
- **Observed:** the rows render without that card heading; **the screen has no `h3`.** All controls (`Add cost`, `Save costs`, basis select, assumed badge, on/off toggle) are present, and the eight basis options match the baseline verbatim.
- **Evidence:** `02_EVIDENCE/admin/analytics-costs/analytics-costs-main@1440-mockup.png`
- **Files:** not identified in the source.

**F-ADM-UI-18 · S4 · deviation · growth · Label / copy · Promotion column shows the code over the scope — low confidence, may be data**
- **Baseline:** the promotion table's first column shows the human label with a trigger icon over a generated condition summary — e.g. `Eid platform deal — 15% off` / `Auto-applied deal · up to ⃁ 50 · 1/customer`.
- **Observed:** the first column shows the promotion **code** in a monospace face over the scope — e.g. `WelcomeAgain` / `NAC · 1 service`, `SUMMER20` / `Platform-wide`. The trigger icon, discount badge, usage bar, status, expiry and edit/delete affordances **all match the baseline.**
- **Confidence caveat (carry this):** staging promotions may simply carry no bilingual label, in which case the code is a reasonable fallback. **This could not be distinguished from a rendering choice** — resolve by checking whether a staging promotion has a label before treating it as a defect.
- **Evidence:** `02_EVIDENCE/admin/growth/growth-main@1440-{mockup,staging}.png`
- **Files:** not identified in the source.

---

# Open questions — must be resolved, not assumed

## OQ-FIT-A · `charge.transfer_request_id` column type under strict mode
**Attached to: F-FIT-01+05 (S1). Do not close that item without resolving this.**

`WithdrawalBundlingService` assigns a string (`'NTR-…'`) into the int column
`charge.transfer_request_id` (`m260623_211000_create_charge_ledger.php:43`). Under MySQL strict
mode this raises error 1366 inside `createWithdrawal()`'s transaction, rolling back the entire
request — meaning **"Request transfer" would fail outright for any shop with notification
charges.** That is a different and more severe failure than the leak F-FIT-01+05 describes.

**Resolved as latent, not active** (live ledger probe, 2026-08-06): the assignment touches only
charges of type `sms`/`whatsapp`, **zero such rows exist on staging**, so the path has never
executed. Eight transfer requests have completed successfully, which is consistent with the path
never being reached rather than with strict mode being off.

**Still undetermined:** `@@sql_mode` on the deployed database has not been checked, so whether the
consequence is silent coercion to 0 or a transaction rollback is unknown. **The risk becomes
reachable the moment the first notification charge exists.**

**To resolve:** run `SELECT @@sql_mode` on the deployed database, then trigger a shop "Request
transfer" for a shop whose bookings carry at least one SMS or WhatsApp charge row (**P-NOTIFCHG**)
and inspect `charge.transfer_request_id` for those rows plus whether a `withdrawal` row was
created at all.

## OQ-NOTIF-B · Can a shop be billed for a message that was never sent?
**Attached to: H7 and NOTIF-02. Stated as a hypothesis, not a finding. The probe has not been run.**

If `dispatch()` records a notification and appends a billable `Charge` row on a path where no
provider send actually occurs, a shop could be charged for a message that was never delivered.
The refund mechanism (`markNotifFailed()`) is triggered by a **reported** delivery failure — which
a non-existent gateway would never report, so the refund path may be unreachable on this route.

**If a charge is appended without a send, that is S1 (incorrect money). If the path
short-circuits before billing, there is no money defect and this closes.**

**To resolve — preconditions P-SMSPRICE, P-NOTIFCHG.** Enable a paid channel on a staging shop,
fire the trigger, and inspect whether a `charge` row is appended and whether any delivery attempt
is recorded. It reads as dormant today only because `sms_sell_price` is `0.0000` and zero
notification charges exist — **that is a configuration accident, not a guard.**

## OQ-CAT-C · Does `deleteWithRelated()` delete `hasMany` rows?
**Attached to: F-CAT-04 (S2). Blocked by tooling, not by data.**

`ShopCategory::relationNames()` returns `['services','shops']` and `getShops()` is
`hasMany(Shop, ['category_id' => 'id'])`. If `deleteWithRelated()` deletes `hasMany`-related rows,
then deleting a shop category **attempts to delete every Shop whose `shop.category_id` points at
it — a data-loss path** (probably rolled back by the controller's outer transaction when the later
FK check fails, but that must be confirmed too). If it only detaches or ignores them, F-CAT-04 has
no data-loss exposure and only the missing unlink/cleanup remains.

`vendor/` is not installed in the audit checkout, so `mootensai/yii2-relation-trait` could not be
read. **To resolve:** run `composer install` (with dev deps) and read
`RelationTrait::deleteWithRelated()`.

---

# Sequencing notes

Not a plan — a record of which items are coupled, drawn from the findings themselves.

| Do together | Why |
|---|---|
| CF-CC-01 + F-FIT-04 | Same resolver (§7.2) |
| SE-02 + SE-03 (+ SE-04) | Same root; and SE-03's fix moves shops into the state SE-04 never resolves |
| FIN-LEDGER-01 + FIN-LEDGER-02 | Same missing call site |
| CF-CC-01 → CF-CC-03 | CF-CC-01 masks CF-CC-03; the threshold source must be live before the save-side reconciliation can be observed |
| F-FIT-02 + F-FIT-03 (+ SE-10) | Same symptom — charges never flipped to paid on two rails; SE-10 is the same omission in the same method |
| F-CAT-01 + F-CAT-02 + F-CAT-10 | One `parentOptions()` correction |
| F-CAT-05 + F-CAT-06 | Same `array_replace_recursive` construct |
| F-ADJ-01 → F-RBAC-09, F-CAT-08, CF-CC-08, SE-15, F-ADJ-07 | One emitter, six findings (§7.6) |
| F-RBAC-03 + F-RBAC-04 + F-RBAC-05 + F-RBAC-06 | One catalogue/role-model decision covers the set — **needs a product decision on which personas are wanted before engineering** |
| CF-CC-04 + F-ADJ-05 | Two grace-window anchors, one concept |
| H5.2 (config) → SE-01, SE-14, H5.3 | The matrix must be populated before any of these can be observed |
| F-ADM-UI-02 + F-ADM-UI-03 | Same screen (P&L); both patterns already exist on the portal's own Dashboard |
| F-ADM-UI-04 + F-ADM-UI-05 | Both are the Events filter row |
| F-ADM-UI-11 + F-ADM-UI-14 + F-ADM-UI-17 | Copy changes — each needs both `ar/` and `en/` message files |

**Product decisions embedded in the backlog, not engineering tasks:** SE-05 (the deliberate
permissive entitlement posture), F-RBAC-04 (which roles/personas the platform actually wants),
FIN-LEDGER-06 (the documented redemption-fee divergence, NVG-BEA-008 W4), and the "richer data"
surplus on the shop-edit form (~32 controls across 9 groups the demo does not model, listed in
`_hypotheses_h1_h2.md` and explicitly **not** defects).
