# Shop portal — remediation backlog

**Source:** `03_FINDINGS/shop/` (7 area files + `_hypotheses.md` + `_ui-parity.md`).
**Verdict and context:** `REPORT_SHOP.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" 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.

---

> ## ⚠ Ordering constraint — read before scheduling any finance item
>
> **F-FIN-01 and F-FIN-10 must ship together.** F-FIN-01's spurious walk-in processing fee lands
> only on displayed figures and the invoice rail **because** F-FIN-10 omits processing fees from
> payout. **Fixing F-FIN-10 alone converts a display error into a payout error, charging shops
> 19.67 they do not owe on every walk-in.** Full statement in `REPORT_SHOP.md` §6.

---

## 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 therefore also serves the admin portal. Fixing once serves both. |
| **[→ ADMIN]** | The same code or the same behaviour is already carried in `BACKLOG_ADMIN.md`. **Cross-referenced, not duplicated** — schedule once, verify on both portals. |

## 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, and a *fix* verified against a zero looks like a no-op. 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), then create **fresh** records | Commercial rates read zero across staging for most of this audit; a probe raised them mid-way and left them raised, but rows written earlier carry their stamped zero rate. Every fee on the pre-existing ledger resolves to zero, so fee arithmetic cannot be validated in either direction against old data. |
| **P-OFFER** | Create an offer with `rate_discount_pct > 0` and enrol the shop | No enrolled offer carries a non-zero discount, so F-FIN-06 reads as parity today. |
| **P-PKG** | Author a subscription package, sell it to a `navagoo_sourced` customer, and redeem at least one session | Staging has **zero service bundles**. Every packages item (four S1s) is unobservable until one exists. |
| **P-OVERNIGHT** | Configure a shop with `close_at <= open_at` (e.g. open 18:00, close 02:00) and give a specialist an overnight shift | BE-F03 requires an overnight business day; no staging shop currently has one. |
| **P-WALKIN** | Create the booking through the shop portal's **New walk-in** modal, not through the api | The api tier assigns `amount_collected` explicitly, which masks Group A entirely. |
| **P-SMSPRICE** | Set `commercial_config.sms_sell_price` / `wa_sell_price` to a non-zero value | Defaults to `0.0000 NOT NULL`; overage charges are written at 0.00 SAR. **The wrong allowance is stamped regardless of the price.** |
| **P-PLANBUNDLE** | Set `navagoo_subscription_plan.sms_included` to a value different from `CommercialConfig.sms_free_month` on the test shop's plan | SN-01 is invisible while the two agree. |
| **P-NOSUB** | A shop-portal login for a shop with **no** `shop_subscription` row | SN-03's default-allow half could not be reached; the admin Subscription Dashboard lists candidates (`NSH-251210004`, `NSH-251110003`). |

**Two staging facts that are not preconditions but change what a probe means.** The shop's free
trial is live with `trial_consumed = 1`, so the subscription money path cannot be driven without an
irreversible PAID ledger row (H6). The seeded notification triggers were force-approved for in-app
by migration `m260721_215500`, so SN-09 will show nothing against the seeded set.

---

## Counts

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

| Dimension | Severity | Items |
|---|---|---|
| Behavioural (Track L) | S1 Critical | 11 |
| Behavioural (Track L) | S2 Major | 27 |
| Behavioural (Track L) | S3 Moderate | 17 |
| Behavioural (Track L) | S4 Minor | 5 |
| Behavioural (Track L) | Outside the area scorecard (hypothesis outcomes H3, H4, H6) | 3 |
| Behavioural (Track L) | **subtotal** | **63** |
| UI parity (Track U) | S2 Major | 1 |
| UI parity (Track U) | S3 Moderate | 17 as recorded · **20 as enumerated** |
| UI parity (Track U) | S4 Minor | 11 |
| UI parity (Track U) | **subtotal** | **29 as recorded · 32 as enumerated** |
| | **Total** | **92 as recorded · 95 as enumerated** |

**On the two UI figures.** `_ui-parity.md`'s headline records 29 findings (1 S2 · 17 S3 · 11 S4);
its enumerated set carries 32 ids (`F-SHP-UI-01` … `F-SHP-UI-32`) grading to 1 S2 · 20 S3 · 11 S4.
The S2 and S4 counts agree; the difference sits entirely in the S3 band. **This backlog carries all
32 enumerated items so none is silently dropped**, and states both totals rather than choosing one.
See `REPORT_SHOP.md` §7 and §13.

**Relation to the admin backlog.** `BACKLOG_ADMIN.md` carries **112** items (94 Track L + 18
Track U). The two backlogs together hold **204** items on the recorded count (**207** on the
enumerated one) — but **they are not 204 independent pieces of work.** At least eleven shop items
are the same shared `common/` code as an admin item, marked **[→ ADMIN]** below and listed in the
next section. Deduplicating those, the shop phase adds roughly **80 new Track L items** to what the
admin phase already registered.

---

# Shared with the admin backlog — cross-referenced, not duplicated

These items are the same code, the same behaviour, or the same missing wiring as an item already in
`BACKLOG_ADMIN.md`. **Schedule them once.** The severity may differ between the two portals because
the observable consequence differs; both gradings are shown.

| Shop item | Sev here | Admin item | Sev there | What is shared |
|---|---|---|---|---|
| **F-FIN-01** + **CF-CC-04** | S1 | `FIN-LEDGER-04` | S2 | `FinanceLedgerService::amountCollected()` falling through to `total_amount`. The admin item states a downgrade condition ("if the walk-in creation path writes 0…"); the shop evidence bears on it directly — `WalkInBookingService` writes neither the column nor a zero. |
| **F-FIN-02+03** (ledger side) | S1 | `FIN-LEDGER-01` + `FIN-LEDGER-02` | S1 + S2 | No completion transition invokes `deriveBookingCharges()`; `ensureEarnings()` calls `ensureProcessingFee()` only. **The admin item carries the same "do not point remediation at the payout rail" caveat.** The shop finding adds the legacy-rail half (`Earnings::calculateNavagooMarketingFees` charging ungated), which the admin item does not cover. |
| **PKG-01+02** (site two) | S1 | `FIN-LEDGER-07` | S2 | `deriveBookingCharges()` has no package-link guard, so a portal cancel of a redemption raises a second marketing fee. |
| **PKG-04** | S2 | `F-FIT-13` | S3 | A package sale raises fee rows but no `ShopEarning`, so `eligibleEarnings()` can never select one. |
| **PKG-07** | S2 | `FIN-LEDGER-08` | S3 | `buildPackagePurchaseCharges()` never consults the grace window. |
| **F-FIN-06** | S2 | `CF-CC-02` | S1 | `OfferPricingService::rateDiscountPct()` has no caller; the fee-rate resolvers never consult an enrolment. |
| **F-FIN-07** | S2 | `F-FIT-06` | S2 | Cancelled and no-show bookings create no `ShopEarning`, so retained money never becomes settle-eligible. |
| **F-FIN-11** | S3 | `F-FIT-09` + `F-FIT-12` | S3 + S3 | No scheduled billing/threshold pass; no single-open-threshold-invoice guard. |
| **SN-01** | S2 | `NOTIF-02` | S1 | `freeLimitFor(string $channel)` takes no shop or plan argument, so `sms_included` / `wa_included` are unread at billing time. |
| **SN-05** | S3 | `NOTIF-03` | S2 | Approval is per-trigger; the per-channel columns added by `m260728_100100` have no reader. |
| **SN-03** | S3 | `SE-05` | S2 | `shopCanAccess()`'s `$defaultAllow = true`. **Documented as a deliberate live-rollout posture — a product decision, not an engineering defect.** |
| **SN-04** + **CF-CC-06** | S2 | `SE-06` | S2 | Payment-method availability ignores the plan's gating feature (`mada_apple_pay`, `deposits_pos`). |
| **CF-CC-07** | S3 | `SE-07` | S3 | The three `walkin_*` columns are rendered as checkboxes and dropped by `scenarios()` mass assignment. |

**The notification-dispatch gap is not repeated here at all.** `NotificationDispatchService::emit()`
reaching no provider while `billPaidSend()` still appends a `Charge` is carried in
`BACKLOG_ADMIN.md` as **H7** and as open question **OQ-NOTIF-B** ("can a shop be billed for a
message that was never sent?"), with the same preconditions (P-SMSPRICE, P-NOTIFCHG). The shop
re-test confirmed it unchanged and added one precision that must travel with it: **provider gateways
do exist and are wired elsewhere** — Msegat/Twilio for OTP, the T2 WhatsApp API for invitation
campaigns — **so the gap is specific to the notification-trigger dispatch path, not to the
platform's messaging capability.** See `REPORT_SHOP.md` §8, H7.

**One shop item lands in admin-tier code.** F-FIN-09's only cited file is
`backend/controllers/AdminFinanceController.php` (`collectableEarnings` summing `total_amount`
rather than what was collected). The consequence graded is shop-facing; the fix is not. It is
adjacent to `BACKLOG_ADMIN.md` F-FIT-13, which touches the same method for a different reason —
whether they are one ticket was not assessed.

---

# S1 — Critical

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

---

## Group A — one assignment on the walk-in create path, closes 2 S1 + 1 S2 **[common/ + frontend]**

> **Shared fix.** `WalkInBookingService::create` assigns neither `payment_mode` nor
> `amount_collected`. `FinanceLedgerService::amountCollected()` then walks
> `['amount_collected','final_collected_amount','paid_amount','total_amount']`; the `booking` table
> carries only the first (nullable, no default) and the other two live on `earnings`/`payment`, so
> the chain falls to `total_amount`, which is never null. **Assigning the payment split at creation,
> and suppressing the processing row when the online amount is `<= 0`, closes F-FIN-01 (S1),
> CF-CC-04 (S1) and its folded symptom CF-CC-08 (S2).**
>
> **⚠ Ship this with, or before, Group B's F-FIN-10. Never after.** See the ordering constraint at
> the top of this file.

### F-FIN-01 · S1 · shop-finance · A pay-on-visit booking reads as fully collected **[common/] [→ ADMIN FIN-LEDGER-04]**
**Contract:** C-SHP-024 · **Cause:** deviation · built-not-working

- **Baseline:** the processing-fee basis is the **online** amount collected only; `amountCollected <= 0` produces **no processing row at all**, and a pay-on-visit booking carries no processing fee even after completion. Mockup `finance.ts:118` returns null when `amountCollected <= 0`.
- **Observed:** `amountCollected()` (`FinanceLedgerService.php:1099`) falls through to `total_amount` for any booking the shop portal created. `WalkInBookingService` never assigns `amount_collected`; `actionCollect` writes `in_store_collected` instead. The same helper feeds `customerRefund`, the uncollected-no-show guard, `bookingNavagooPayout` and `bookingSettlement`, so the error propagates into the refund recorded, the payout and the settlement strip.
- **Worked example:** walk-in **460.00** cash, 3.5% + 1.00, VAT 15% → charge **17.10** + **2.57** VAT = **19.67 of fee that must not exist.**
- **Internal contradiction (carry this):** `outstandingBalance()` reads `amount_collected` directly and resolves to 0 for the same booking. **The same booking is unpaid for the collect UI and fully collected for the fee engine.**
- **Rail (carry this):** displayed figures + the invoice rail, **not** payout. That is the whole reason for the ordering constraint.
- **Files:** `common/components/FinanceLedgerService.php`, `common/migrations/db/m260608_120100_add_payment_mode_to_booking.php`, `frontend/controllers/BookingController.php`
- **Verify — preconditions P-RATES, P-WALKIN.** Create a pay-on-visit booking through the shop portal, complete it with money taken in store, then read `booking.amount_collected` / `in_store_collected` and `SELECT * FROM charge WHERE booking_id = <id>`. Confirmed if `amount_collected IS NULL` and a `processing_fee` row exists whose `base_amount` equals `booking.total_amount`.
- **Withdraw / narrow condition (from the source):** `amount_collected = 0` **and** no processing row would mean the fallback is not reachable in practice, and **the finding drops to the api-created case only.**

### CF-CC-04 · S1 · collect-complete · The collect path stamps a processing fee on counter cash **[common/] [→ ADMIN FIN-LEDGER-04]**
**Contract:** C-SHP-071 (folded symptom: C-SHP-074) · **Cause:** deviation · built-not-working

- **Baseline:** in-store money raises **no** charge rows and never produces a processing fee.
- **Observed:** the collect path calls `BookingCompletionService::ensureEarnings`, which always calls `ensureProcessingFee`; `buildProcessingFee` resolves its basis through the same fallback chain. **`api`-created bookings set `amount_collected` explicitly (including 0), so the defect is specific to bookings created in the shop portal.**
- **Worked example:** portal walk-in **460** cash → `payment_processing_fee`, `base_amount 460.00`, 3.5% + 1.00 = **17.10 + 2.57 VAT = 19.67 netted from settlement**, **non-refundable by design**, on money that never touched the rail.
- **Files:** `common/components/FinanceLedgerService.php`, `common/services/BookingCompletionService.php`, `common/services/WalkInBookingService.php`, `frontend/controllers/BookingController.php`
- **Verify — preconditions P-RATES, P-WALKIN.** Create a walk-in via the portal, run **Collect & complete** for cash, then `SELECT * FROM charge WHERE booking_id = <id>`. A `payment_processing_fee` row with `base_amount` equal to the booking total confirms it; zero processing rows would clear it. Also confirm from the live schema that `booking` has no `paid_amount` and no `final_collected_amount` column — **if either exists, the fallback resolves earlier and the finding needs re-derivation.**

### CF-CC-08 · S2 · collect-complete · The online / deposit / on-visit split is not implemented on the solo path
**Contract:** C-SHP-074 · **Cause:** gap · not-built · *Downgraded from S1 and folded into CF-CC-04 — same root cause, same fix. Kept visible so the fix is not written twice.*

- **Baseline:** online → collect the full total now; deposit → `round2(total × depositPct/100)` now; on_visit → 0 now; the remainder is always `max(0, round2(total − collectedNow))` due on visit.
- **Observed:** the split exists only on the party path (`GroupBookingService::collectAmount`, `:1017-1026`, applied at `:359-361`). On the solo path `WalkInBookingService::create` references neither `payment_timing` nor `payment_mode` nor `amount_collected`, and `booking-new.js:824-828` sets the pay-now due to the full services total for both `deposit` and `on_visit` — source comment: *"The timing select is presentational for create"*.
- **Worked example:** `depositPct = 30`, total **460** — the baseline collects **138.00** now and leaves **322.00** due on visit; the portal records nothing collected online and presents **460.00** as due in store.
- **Why it is S2, not S1 (carry this):** **no wrong number reaches anyone.** The portal makes no gateway call on this path, so a deposit was never charged, and collecting the full amount in store is arithmetically correct for money actually owed. What remains is a UI control that does nothing.
- **Files:** `common/services/WalkInBookingService.php`, `frontend/web/js/booking-new.js`, `common/components/GroupBookingService.php`
- **Verify:** no financial precondition. Create a walk-in choosing **deposit**, then read `booking.payment_mode` and `amount_collected`. Confirmed if they read `online` / NULL regardless of the timing selected.

---

## Group B — the dual finance rail: two S1s that must be reasoned about together **[common/]**

> **Shared context, not a single shared fix.** The `charge` ledger and the legacy
> `Earnings` / `shop_earning` engine both run. F-FIN-02+03 is the marketing fee sitting on the wrong
> rail; F-FIN-10 is the payout reading only that legacy rail. They need different code changes but
> **one decision** — which rail is authoritative for what the shop is shown and paid. Fixing either
> without that decision moves a number without reconciling the two views.
>
> **⚠ F-FIN-10 must not ship ahead of Group A's F-FIN-01.**

### F-FIN-02+03 · S1 · shop-finance · Dual-rail marketing fee — one defect, two code fixes **[common/] [→ ADMIN FIN-LEDGER-01 / FIN-LEDGER-02]**
**Contract:** C-SHP-027 · **Cause:** deviation · built-not-working
*Recorded separately as F-FIN-02 and F-FIN-03; re-verification established one defect under one contract. **Fixing either side alone leaves the shop on a wrong number.***

- **Baseline:** a marketing-fee row is created **iff** the classification is `navagoo_sourced`; pending while provisional, locked once terminal; a completed pay-on-visit booking still raises it; for `shop_owned` **no row of any amount** is created. The fee is floored at the configured minimum and waived to zero inside the grace window.
- **Observed — side one (`gap · not-built`), the ledger rail goes empty:** `deriveBookingCharges` — the only caller of `buildMarketingFee` — is reached from four places only (group creation, group cancels, `BookingController::actionCancel`, the backend classification override, plus `DemoController`). The completion chokepoint `BookingCompletionService::ensureEarnings` calls `ensureProcessingFee()` and nothing else.
- **Observed — side two (`deviation · built-not-working`), the legacy rail charges ungated:** `Earnings::calculateNavagooMarketingFees` (`Earnings.php:591-596`) **takes no classification argument at all** and computes `shop.platform_commission% × net_collected_excl_vat` on every completed booking, with **no classification check, no minimum-fee floor and no grace-window waiver**, stamping `earnings.navagoo_marketing_fees` → `shop_earning.navagoo_marketing_fees`. That column drives the Earnings table's fee column and `net_collectible_amount` — what the withdrawal actually nets.
- **Worked example — displayed side:** navagoo-sourced **460** online → the tile shows Navagoo fees **19.67** vs a baseline **42.67** and Net earnings **440.33** vs **417.33**, while the row beneath shows **23.00** from the legacy column. **Same screen, two answers for one booking.**
- **Worked example — payout side:** `shop_owned` walk-in **460**, `platform_commission` 5% → `net_collectible_amount` = **−23.00**; the payout is short **23.00** where the baseline charges zero.
- **Files:** `common/services/BookingCompletionService.php`, `common/components/FinanceLedgerService.php`, `frontend/controllers/BookingController.php`, `common/models/base/Earnings.php`, `common/helpers/BookingHelper.php`, `common/models/base/ShopEarning.php`, `frontend/controllers/EarningsController.php`
- **Verify — precondition P-RATES.** Complete a navagoo-sourced solo booking through the portal and query `charge` for it (**zero `marketing_fee` rows confirms side one**); then query `shop_earning.navagoo_marketing_fees` for a booking whose `customer_classification` row is `shop_owned` (**a non-zero value confirms side two**). **At zero rates side one is invisible and side two still shows, because the legacy rail reads `platform_commission`, not the commercial rates.**

### F-FIN-10 · S1 · shop-finance · The payout never subtracts processing fees **[common/]**
**Contract:** C-SHP-041 · **Cause:** deviation · built-not-working

- **Baseline:** `netPayout = Σ collected + tips − marketing − processing − notif − other fees − fee VAT + Σ package net payouts`. Mockup `store.ts:1207-1250`, with `otherFeeRows` explicitly `!c.bookingId`.
- **Observed:** `WithdrawalBundlingService::buildAndPersist` (`:261`) accumulates `shop_earning.net_collectible_amount` then subtracts only `total_notif_fees`. `TYPE_PROCESSING_FEE` appears **nowhere in the file**. `ShopEarning::calculateNetCollectibleAmount` = `final_collected + tip − navagoo_marketing_fees − vat_navagoo`. Package sales are not batched at all, and the notification query fetches only SMS/WhatsApp rows **that carry a `booking_id`**, so non-booking settlement fees such as invitation messaging are never netted.
- **Worked example:** one eligible online booking **460**, processing **17.10 + 2.57** VAT, marketing **20.00 + 3.00** → portal pays **437.00**, baseline **417.33** — **19.67 overpaid**; an unpaid invitation SMS fee of **0.29** with no `booking_id` takes it to **19.96**.
- **Files:** `common/components/WithdrawalBundlingService.php`, `common/models/base/ShopEarning.php`
- **Verify — precondition P-RATES, and Group A shipped first.** Take a shop with one eligible online booking carrying a processing-fee row, request a transfer, and compare `withdrawal.net_transferable_amount` against the baseline formula. **Do not verify this in isolation: with Group A unfixed, correcting the payout starts deducting the spurious 19.67 from every walk-in, and the arithmetic will still look right.**
- **Related, same method, not closed by this:** `BACKLOG_ADMIN.md` F-FIT-08 (the payout amount does not consult `charge.status`).

---

## Group C — one package-redemption guard, closes 4 S1 + up to 3 S2 **[common/ + frontend]**

> **Shared fix.** Nothing on any portal path asks whether a booking is a package redemption.
> `Booking::getIsPackageRedemption()` (`common/models/Booking.php:57-60`) exists and has **zero call
> sites repo-wide**; `PackageEntitlement::reinstateSession()` (`:206`) and
> `FinanceLedgerService::reversePackageRedemptionCharge()` (`:1503`) exist, are correct, and are
> called **only from `api/controllers/BookingController.php:1185,1191`.** Introducing the guard at
> the four portal read/write points — charge derivation, revenue, outstanding balance, cancel — and
> calling the two existing helpers from the portal cancel path closes **PKG-01+02, PKG-03, PKG-05
> and CF-CC-02 (all S1)** and materially reduces PKG-04, PKG-07 and PKG-08.
>
> **This is the highest-leverage group in the backlog: four of the eleven S1s, one concept.**

### PKG-01+02 · S1 · packages · A redeemed session is charged marketing fees twice, then again on cancel **[common/] [→ ADMIN FIN-LEDGER-07 for site two]**
**Contract:** C-SHP-064 · **Cause:** deviation · built-not-working
*Merged: one defect at **two code sites, both of which must be fixed** — fixing either alone leaves the other live.*

- **Baseline:** a booking carrying the package link produces **no ledger rows at all** — not marketing, not processing — even for a `navagoo_sourced` customer, because all Navagoo money was taken at the sale. Mockup `finance.ts:335` returns early on `customerPackageId`, and the package-link guard is **first** in the derivation.
- **Observed — site one:** `buildPackageRedemptionCharges()` (`FinanceLedgerService.php:1429-1467`) has no zero-charge guard and stamps a marketing-fee `Charge` at redemption for `navagoo_sourced` customers (basis `noVat(entitlement.per_session_price)`, floored at `minMarketingFee`, VAT on top, status UNPAID).
- **Observed — site two:** `deriveBookingCharges()` has no `package_entitlement_id` branch and `buildMarketingFee()` has no per-booking existence check — unlike `buildProcessingFee()`, which guards at `:331-339`. A portal cancel (`BookingController.php:780`) computes a full booking-style marketing fee on the redemption's `total_amount` and **appends** it.
- **Worked example:** package **760** over **4** sessions, marketing 5%, VAT 15%. Purchase stamps **33.04**; the four redemptions add **33.04** more (a double charge on the same money); a shop cancel of one visit adds a further **8.70**, unreversed because the reversal branch is skipped when `cancelled_by = shop` — taking that one visit to **16.96 against a baseline of 0.00.**
- **Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/BookingController.php`
- **Verify — preconditions P-PKG, P-RATES.** Redeem a session, then cancel the resulting visit **from the shop portal**, and list every `charge` row for that booking id. Confirmed if two `marketing_fee` rows exist for one redeemed session — one on the per-session price, one on the full booking value.

### PKG-03 · S1 · packages · A completed redemption mints withdrawable credit for money never collected **[common/]**
**Contract:** C-SHP-065 · **Cause:** deviation · built-not-working

- **Baseline:** `bookingRevenue = 0` for a booking with a package link — the value was recognised at the sale. Mockup `finance.ts:508`.
- **Observed:** `bookingRevenue()` (`:654-660`) has no package guard, and `SiteController::shopRevenueForWindow` sums it. Completing the visit also runs `ensureEarnings()`, which mints Payment + Earnings + ShopEarning: `BookingHelper.php:612` sets `earnings.amount = booking.total_amount`, `Earnings::calculateFinalCollectedAmount()` makes `final_collected_amount = 200`, and `ShopEarning::calculateNetCollectibleAmount()` turns that into **withdrawable cash**.
- **Worked example:** four completed redemptions at a catalogue price of **200** → dashboard revenue **+800** (baseline 0) **and 800 of withdrawable settlement credit for visits where 0.00 was collected**, against a **760** sale.
- **Severity note (carry this):** the original grading understated this. **The settlement credit, not the dashboard figure, is the larger consequence** — the dashboard overstates a number, the settlement rail hands over money.
- **Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/SiteController.php`, `common/services/BookingCompletionService.php`
- **Verify — precondition P-PKG.** No rate precondition — this is a revenue/earnings defect, not a fee defect. Complete a redemption visit, then read `shop_earning` / `earnings` for that booking id and the dashboard revenue window. Confirmed if an earnings row exists with `final_collected_amount` equal to the catalogue price.

### PKG-05 · S1 · packages · A shop cancel destroys a paid session and leaves its fee standing — data loss
**Contract:** C-SHP-066 · **Cause:** deviation · not-built

- **Baseline:** a redemption visit is pre-paid and already consumed, so a cancellation is **rejected as a no-op** unless the platform explicitly enables package cancellations (default off). The baseline implements **both** the block and the admin toggle (`store.ts:1066-1070`, `Settings.tsx:240`), so it is designed behaviour, not an unconsumed flag.
- **Observed:** `actionCancel()` (`:708-811`) read in full — no `package_entitlement_id` branch, no `reinstateSession()`, no `reversePackageRedemptionCharge()`. **No model hook acts as a safety net**, and the second portal cancel path (`AgentsBookingsController.php:331`) is equally bare.
- **Worked example:** a 4-session package with one session redeemed (remaining 4 to 3); the shop cancels the visit — the status flips, remaining **stays 3**, and the entitlement can reach EXHAUSTED early. **The customer permanently loses 190.00 of paid entitlement** and the **8.26** redemption fee stands.
- **Scope note (carry this):** the equivalent customer-initiated path in the `api` tier **does** reinstate within the full-refund window. That tier is out of scope and is not graded; it is named because it is where the working implementation lives.
- **Files:** `frontend/controllers/BookingController.php`, `common/components/FinanceLedgerService.php`
- **Verify — precondition P-PKG.** Redeem a session, note `sessions_remaining`, cancel the visit from the shop portal, re-read the entitlement. Confirmed if `sessions_remaining` is unchanged and the redemption `marketing_fee` row carries no reversal.

### CF-CC-02 · S1 · collect-complete · A pre-paid redemption cannot be completed without recording money never taken **[common/]**
**Contract:** C-SHP-069 · **Cause:** gap · not-built

- **Baseline:** `outstandingBalance` is forced to **0** for a package-redemption visit, so it completes without any collection step. Mockup `finance.ts:520` opens with `if (b.customerPackageId) return 0` and its comment names this exact failure.
- **Observed:** `outstandingBalance` (`:1087-1094`) has no package branch, and `Booking::getIsPackageRedemption()` has **zero call sites**. The redemption booking is persisted with `total_amount` = the service's real value and `amount_collected = 0`, so outstanding resolves to the full service value.
- **Worked example:** a **460 SAR** pre-paid session — **Complete** is refused with `requireCollect`, outstanding shows **460**, the badge stays *pending* permanently. The only escape is **Collect & complete**, which writes `in_store_collected = 460.00`, `in_store_method = 'cash'` **for money never taken.**
- **Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/BookingController.php`, `common/models/Booking.php`
- **Verify — precondition P-PKG.** No rate precondition. Locate or create a redemption booking (`payment_mode='package'`, `package_entitlement_id` set) and call the portal's **Complete** action. A `requireCollect` refusal quoting the full service value confirms it; completion succeeding would mean the redemption path zeroes the balance somewhere not visible in source.

---

## Group D — one client-side response check plus a status decision

### CF-CC-05 · S1 · collect-complete · The pay-now collection and card tip are discarded silently
**Contract:** C-SHP-073 · **Cause:** deviation · built-not-working

- **Baseline:** the collect flow records the collection and reports the collected amount plus tip and method. The mockup's create-then-pay-now path records the collection against the new booking **without completing it** — `collectPayment` (`store.ts:1136-1168`) writes only `inStoreCollected` / `inStoreMethod` / tip. **The portal fails on a step the mockup never takes.**
- **Observed:** the Pay-now sub-step posts to `/booking/collect` (`booking-new.js:892-915`) against a booking created as `STATUS_SCHEDULED` (`WalkInBookingService.php:148`). `defaultTransitions()` maps SCHEDULED to `[in_progress, no_show, cancelled]`, so `actionCollect`'s first guard (`BookingController.php:331-334`) returns `success:false` with *"This booking cannot be completed from its current status."* `booking-new.js:911-912` resolves **both** the `.then` and the `.catch` to `self.done()` — **the response body is never read** — and `done()` posts `status:'saved'`.
- **Worked example:** create a walk-in for **460**, Pay now, cash → **HTTP 200 with `success:false`**, the modal closes reporting *saved*, `in_store_collected` stays NULL, status stays SCHEDULED, the card tip is discarded. **Cash is in the till and unrecorded.**
- **Two distinguishable defects in one flow (carry this):** the client not reading the response, and the flow posting a collect against a status the FSM rejects. **Fixing only the client turns a silent loss into a visible error message; it does not make the collection work.**
- **Files:** `frontend/web/js/booking-new.js`, `frontend/controllers/BookingController.php`, `common/services/WalkInBookingService.php`, `common/components/BookingTransitionService.php`
- **Verify — precondition P-WALKIN; requires a browser or curl session.** Open New walk-in, choose Pay now, complete the flow, then inspect the created booking's `in_store_collected` / `status` **and the network response of `POST /booking/collect`**. Confirmed by an HTTP 200 body of `{success:false, message:'This booking cannot be completed from its current status.'}` together with `in_store_collected` NULL and status still SCHEDULED. **This probe was not run — the finding rests on code read plus the UI pass's F-SHP-UI-03 observation.**
- **Related UI item, same flow:** F-SHP-UI-03 (S3) — pressing `Pay now` persists the booking immediately where the baseline creates it only after a payment method is chosen.

---

## Group E — the booking-engine writers

> **Not a shared fix — two independent writers, grouped because both are creation/move-path
> omissions beside a correct implementation the writer does not reach.** BE-F01's `checkPlacement`
> is called correctly from the reschedule, calendar and group paths; BE-F03's forward conversion and
> day-window model are correct (C-SHP-008 passes) and only the inverse fails.

### BE-F01 · S1 · booking-engine · Booking creation never runs the placement check
**Contract:** C-SHP-017 · **Cause:** deviation · built-not-configured

- **Baseline:** creation runs the **full** placement check — overlap, time-off, outside-availability, cannot-perform, with the selected service ids — as an authoritative guard after building the booking and before any persistence. Mockup `store.ts:3376-3384` runs it before persisting, **explicitly to catch programmatic misuse.**
- **Observed:** `checkPlacement` is called from `BookingController:536`, `BookingCalendarController:570`, `GroupBookingService:295/:528` — **never from `WalkInBookingService`**. `checkBlockConflicts` runs only an `agent_slots` overlap query plus a portal-only "earlier open booking" rule. Time-off lives in the separate `AgentTimeOff` table, invisible to both; `workingBlocks` / `canPerform` are never consulted. The modal pre-filters using server-generated slot chips, **but that list is a snapshot.**
- **Worked example:** manager A opens New walk-in for specialist S with the 14:00 slot list loaded; manager B adds S a 13:00–15:00 time-off; A submits → **the booking is created inside the time-off**, rendered over a shaded zone, and the specialist is double-committed.
- **Files:** `common/services/WalkInBookingService.php`, `frontend/controllers/AgentsBookingsController.php`, `frontend/components/BookingScheduleService.php`
- **Verify:** no financial precondition. POST directly to `/agents-bookings/create-walk-in` — **bypassing the modal so its client-side slot list cannot pre-filter** — with a `booking_date` + `from_hour` that falls (a) inside an existing `agent_time_off` row for the chosen specialist, (b) entirely outside that specialist's `user_shift` for that weekday, and (c) with a service id the specialist has no `user_shop_service` row for. Any of the three returning success with a persisted booking confirms it.
- **Note:** BE-F02 (S2) is the inverse defect in the same `canPerform()` rule and should be settled before this one's leg (c) is read as passing — see S2.

### BE-F03 · S1 · booking-engine · An overnight post-midnight slot is persisted against the wrong calendar day
**Contract:** C-SHP-009 · **Cause:** deviation · built-not-working

- **⚠ Attribution correction — read before opening any file.** `fromBusinessMinutes()` (`BookingScheduleService:192-195`) does collapse the value mod-1440, **but it has zero callers — it is dead code. Patching it changes nothing.** The live sites are below.
- **Baseline:** the inverse of `toBusinessMinutes` must roll the calendar date forward above 1440 and return a local wall-clock timestamp. `schedule.ts:114-121` computes `dayOffset = floor(min/1440)` and rolls the date, with a test asserting `('2026-05-19', 1500) === '2026-05-20T01:00:00'`.
- **Observed (live sites):** `freeSlots:541` emits the business-day date with `minToClock($start)` while `$start` runs on the business axis up to 1560 (`workingBlocks:222` adds 1440; `shopWindow:247-249`), and `minToClock:112` collapses mod 1440. `moveBooking` (`BookingController:541-549`) persists `booking_date` as the business-day date with `minToClock($startMin)`. Neither rolls the date. `businessDayOf()` then re-attributes the row to the **previous** business day, so `dayLayout` drops it and `checkPlacement`'s overlap scan skips it.
- **Worked example:** shop open 18:00, close 02:00; business day **2026-08-05**; business minute **1500** → slot value **`2026-08-05 01:00`** where it should be **`2026-08-06 01:00`**. The row persists `booking_date='2026-08-05'`, `from_hour='01:00'`; `businessDayOf('2026-08-05', 60)` resolves to **`2026-08-04`**; the row lands on the previous night's session and **the 01:00 slot is offered again, producing a double booking.**
- **Files:** `frontend/components/BookingScheduleService.php`, `frontend/controllers/BookingController.php`
- **Verify — precondition P-OVERNIGHT.** Open the slot picker for that business day and inspect `/booking/slots` for a post-midnight candidate. Confirmed if the slot's `value` reads `<businessDay> 01:00` rather than `<businessDay+1> 01:00`; accept it and reload the day calendar — the booking failing to render on that business day, and its slot being offered again by a second call, confirms it end to end.
- **Do not regress C-SHP-008:** the forward conversion and the `{startMin: 660, endMin: 1560}` window model **pass** and reproduce the baseline's worked example exactly. Only the inverse is wrong.

---

# S2 — Major

27 items, grouped by area. **B** = baseline, **O** = observed. Every item names its verification
precondition, or states that none is needed. Items already scheduled by an S1 group are
cross-referenced rather than restated.

## Shop finance (6)

**F-FIN-05 · C-SHP-035 · gap · not-built · No status change re-derives a booking's ledger rows [common/]**
- **B:** every booking status change re-derives that booking's ledger rows, replacing only rows linked to this booking, of type `marketing_fee` or `payment_processing_fee`, not paid, and not attached to a transfer request.
- **O:** no status-change path re-derives. `deriveBookingCharges` **appends and never deletes or replaces.** The only replace-style implementation is the backend classification override (`UserController.php`), which deletes `marketing_fee` rows in (unpaid, pending, reversed) before re-deriving — it does **not** exclude rows already carrying a `transfer_request_id`, does not cover `processing_fee`, and removes reversal rows.
- **Files:** `common/components/FinanceLedgerService.php`, `backend/controllers/UserController.php`
- **Verify — precondition P-RATES.** Move a booking through two status changes and count `charge` rows for it before and after. **Schedule with the S1 Group B decision — a re-derive that runs against the wrong authoritative rail multiplies the disagreement.**

**F-FIN-06 · C-SHP-034 · gap · built-not-working · An enrolled offer's fee discount is never stamped [common/] [→ ADMIN CF-CC-02, S1 there]**
- **B:** an enrolled offer with a non-zero `rateDiscountPct` scales the processing rate, the fixed component and the marketing rate by `(1 − discount/100)`, with the reduced values stamped on the row and the window evaluated against the immutable booking date.
- **O:** the arithmetic (`OfferPricingService::rateDiscountPct`, with the inclusive `enrolledAt`/`lockedUntil` window and the charge-type allowlist) and the data model (`NavagooOffer.rate_discount_pct`, `ShopOfferEnrollment`) both exist, **but a repo-wide grep for `rateDiscountPct` finds only its own unit test.** `processingRatePct` / `processingFixed` / `marketingRatePct` resolve per-shop column → `CommercialConfig` → default and never consult an enrolment.
- **Files:** `common/components/OfferPricingService.php`, `common/components/FinanceLedgerService.php`, `common/models/NavagooOffer.php`
- **Verify — preconditions P-OFFER, P-RATES.** Enrol the shop in an offer with a non-zero fee discount, create and complete a booking, and read the stamped `rate_snapshot` on the charge row. **Dormant today in both portals — a probe without P-OFFER shows parity.**

**F-FIN-07 · C-SHP-038 · deviation · not-built · Cancelled and no-show bookings never enter the settlement pool [common/] [→ ADMIN F-FIT-06]**
- **B:** a booking is settle-eligible when its status is `completed`, `no_show` **or** `cancelled` — all three are earned — and the **rounded whole-day** difference between now and the terminal timestamp (`completedAt`, else `cancelledAt`, else `noShowMarkedAt`, else the appointment date) is at least the shop's settlement hold days. Cancelled/no-show bookings release retained money minus fees.
- **O:** eligibility is computed over `ShopEarning` rows: `settlement_status` in (pending, requested), `withdrawal_id IS NULL`, and `(now − shop_earning.created_at)/86400 >= shop.minimum_elapsed_period_days`. **The anchor is the earnings row's creation time, not a terminal timestamp, and the day difference is a raw float rather than a rounded whole-day count.** `ShopEarning` rows are created only by `ensureEarnings`, which the completion paths call and the cancel/no-show paths do not — so those bookings never enter the pool at all.
- **Files:** `common/components/WithdrawalBundlingService.php`, `frontend/controllers/EarningsController.php`, `common/services/BookingCompletionService.php`
- **Verify:** requires a booking with a **retained deposit** — no rate precondition. Mark it no-show, wait past the hold (or backdate), and check whether a `shop_earning` row exists and whether the settlement Eligibility table lists it.

**F-FIN-08 · C-SHP-039 · deviation · built-not-configured · The running balance is clamped at zero [common/]**
- **B:** `runningBalance = collectable − outstandingFees` and **may be negative**; negative means the shop owes Navagoo, and `carriedBalance = max(0, −runningBalance)`.
- **O:** `shopBalanceView` clamps `withdrawableNow` to `max(0.0, netAvailable)` and returns it as `runningBalance`, with the inline comment *"simple derivation for UI compat"*. That view feeds the dashboard balance panel and the analytics settlement strip, so **neither can render a negative position.** The Earnings tab's Withdrawable tile does carry an *Owed to Navagoo* branch — **and it renders (`−10.40` was observed live)** — but the value it tests is the sum of legacy `net_collectible_amount` over eligible earnings, which never nets the shop's outstanding fee rows.
- **Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/SiteController.php`, `frontend/views/earnings/_earnings.php`, `frontend/controllers/EarningsController.php`
- **Verify — precondition P-RATES (a shop must actually owe something).** Put a shop into a negative position and compare the Earnings tile, the dashboard balance panel and the analytics settlement strip. Confirmed if the tile shows the negative and the other two show zero.

**F-FIN-09 · C-SHP-039 · deviation · built-not-working · Collectable is summed from the booking total, not what was collected [backend/]**
- **B:** `collectable = Σ` over eligible, unconsumed, unsettled bookings of `(amountCollected − refundValue + tips)`.
- **O:** `AdminFinanceController::collectableEarnings` sums `$collected = (float)($booking->total_amount ?? 0)` minus `refund_value` plus `tipping_amount`. **Using the booking's full value rather than what was collected on the Navagoo rail overstates the collectable side for every pay-on-visit and part-collected booking**, which understates the shop's carried (owed) balance and inflates the held-earnings figure netted into an invoice.
- **Files:** `backend/controllers/AdminFinanceController.php`
- **Verify:** no rate precondition. Take a pay-on-visit booking and compare the admin Shop Balances collectable figure against `amount_collected + in_store_collected`. **The file is admin-tier; the graded consequence is shop-facing. Check against `BACKLOG_ADMIN.md` F-FIT-13, which touches the same method for a different reason.**

**F-FIN-16 · C-SHP-037 · deviation · built-not-working · The Earnings table does not use the four ledger methods [common/]**
- **B:** four distinct per-booking figures, each with its own rule, not conflated; a package-redemption visit has revenue 0. **A portal that shows one number for all four fails.**
- **O:** the four methods exist and are correct in isolation, but `buildEarningsRows` computes fees as `navagoo_marketing_fees + vat_navagoo` (legacy columns) and `netEarnings` as value minus those fees, while `navagooPayout` is read from `net_collectible_amount`; only the four header tiles come from `shopPnl` / the ledger. `bookingRevenue` also has no package-redemption branch.
- **Files:** `frontend/controllers/EarningsController.php`, `common/components/FinanceLedgerService.php`
- **Verify — precondition P-RATES.** Compare a booking's row values in the Earnings table against the four ledger methods for the same booking id. **Part of the dual-rail cluster — see S1 Group B and `REPORT_SHOP.md` §9.1. The package half is closed by S1 Group C.**

## Packages (5)

**PKG-04 · C-SHP-067 · gap · not-built · A package purchase mints no settlement entry [common/] [→ ADMIN F-FIT-13]**
- *Downgraded from S1 on re-verification. Cause `gap · not-built`, which the rubric caps at S2.*
- **B:** a package purchase becomes settlement-eligible once the hold days elapse, and its net (collected − fees) joins the shop's withdrawable/running balance alongside booking revenue; a shop with only an eligible package can still request a payout.
- **O:** no settlement entry is minted **by the purchase itself**. `PackagePurchaseService` creates only the entitlement — no ShopEarning, no Payment, no collectible — while `buildPackagePurchaseCharges` **does** stamp the shop's processing + marketing fees against the sale. `eligibleEarnings()` queries `ShopEarning` only and reads no `booking_id IS NULL` charges. **The sale's proceeds do nonetheless reach withdrawable, but by the wrong route and over-generously: via `ensureEarnings` on completed redemptions, per PKG-03.** What is missing is the designed purchase-time settlement path, not the money.
- **Evidence correction (carry this):** as originally written this finding claimed the proceeds never become withdrawable, which contradicted PKG-03. The two asserted opposite things about the same money; the text above separates the purchase-time gap (real) from the proceeds arriving via redemptions (real, and PKG-03's subject). The mockup does implement the purchase-side path (`packageSettlement` / `isPackageEligible`), so this is a genuine gap and not a grading against an unimplemented baseline.
- **Files:** `common/components/WithdrawalBundlingService.php`, `common/components/PackagePurchaseService.php`, `common/components/FinanceLedgerService.php`
- **Verify — preconditions P-PKG, P-RATES.** Sell a package to a navagoo-sourced customer, advance past the settlement hold, then open the shop's Finance screens and request a payout **with no eligible bookings**. Confirmed if the purchase's `booking_id`-NULL processing and marketing charges appear in outstanding-fee totals while its proceeds are absent from withdrawable.

**PKG-06 · C-SHP-060 · gap · not-built · A package has no per-line service model [common/]**
- **B:** a package holds one line per covered service `{serviceId, sessions, pricePerSession, basePerSession}` with an optional per-line discount; only the line's own discount is persisted so a package-level blanket change re-flows to inheriting lines; price and basePrice are derived as `Σ sessions × per-session values`.
- **O:** `SubscriptionPackage` carries a **single** `sessions` count and a **single** `price` over a flat many-to-many service list. Discount is package-level only. There are no per-line sessions, prices, discounts, blanket inheritance or line-derived totals. **This propagates to the owned record (no per-service allowance, C-SHP-061/063) and to redemption (one session per visit regardless of which service, C-SHP-064).**
- **Files:** `common/models/SubscriptionPackage.php`, `common/models/PackageEntitlement.php`, `frontend/controllers/PackageController.php`, `frontend/views/package/subscription-form.php`
- **Verify:** no precondition — a schema/model read. **This is the largest single piece of work in the packages area and gates the per-service sessions-remaining column F-SHP-UI-17 / PKG-09 describe.**

**PKG-07 · C-SHP-062 · deviation · not-built · Package sales inside the grace window are not waived [common/] [→ ADMIN FIN-LEDGER-08]**
- **B:** the grace window waives **only** the marketing amount to 0 (VAT 0) on a package sale while still emitting the audit row with its basis and rate; the processing fee is never waived.
- **O:** `buildPackagePurchaseCharges()` never consults the grace window — no `inGrace` parameter and no `computeInGrace()` call on this path, unlike the booking path at `FinanceLedgerService.php:578`. A shop inside its onboarding grace is charged the **full** marketing fee on every package sale.
- **Files:** `common/components/FinanceLedgerService.php`
- **Verify — preconditions P-PKG, P-RATES.** Sell a package 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.**

**PKG-08 · C-SHP-064 · deviation · built-not-working · The redemption visit is valued from the live catalogue, not the snapshot [common/]**
- **B:** the redemption visit is built with one line per redeemed service priced at the **owned snapshot** `pricePerSession`, and `bookingValue` (the specialist commission basis) equals the summed snapshot prices.
- **O:** the redemption booking's `sub_amount` / `vat` / `total_amount` are computed from the live `ShopService` catalogue price at redemption time, not from the entitlement's snapshotted `per_session_price`. Since the package price is normally discounted relative to the catalogue, **the visit value — and therefore the commission basis and every value the portal displays for it — exceeds what the customer actually pre-paid per session.**
- **Files:** `common/models/PackageEntitlement.php`, `common/components/FinanceLedgerService.php`
- **Verify — precondition P-PKG.** No rate precondition. Redeem a session and compare `booking.total_amount` against `package_entitlement.per_session_price`. **Schedule with S1 Group C — the same guard needs the snapshot price to be the authoritative one.**

**PKG-09 · (no contract id recorded) · gap · not-built · The portal has no owned-package hub**
- **B:** the shop portal carries a Packages nav entry and an owned-package operations hub: customer, package, purchased date, validity with expired and fully-used badges, per-service sessions remaining read from the owned snapshot, and an expandable list of redemption visits.
- **O:** **the frontend tier contains zero references to `PackageEntitlement`** (the only hits are an unrelated `EntitlementFilter`/`EntitlementService` plan gate and one row-type label in `EarningsController`). `frontend/views/package/` holds the catalogue surfaces only. **A shop cannot see who owns its packages or how many sessions remain.**
- **Files:** `frontend/controllers/PackageController.php`, `frontend/views/package/subscriptions.php`, `frontend/views/layouts/menu/Menu.php`
- **Verify — precondition P-PKG (to see the populated state).** Open the third nav slot. **This item carries no `contractId` in the source run.** Its UI face is **F-SHP-UI-17 (S2, Track U)** — the same absence seen from the nav; the two are one piece of work. **F-SHP-UI-17's own downgrade condition applies: if a populated `/package` renders customer-owned rows with sessions-remaining, the UI item drops to a relabel and this item narrows accordingly.**

## Booking engine (5)

**BE-F02 · C-SHP-011 · deviation · built-not-working · An unlinked service is performable by nobody**
- **B:** the cannot-perform rule rejects **only** when the service HAS a non-empty specialist link list that omits this specialist. An empty or absent link list means anyone can perform the service.
- **O:** `BookingScheduleService::canPerform()` reads the specialist's `user_shop_service` rows and requires the specialist to own **every** requested service id (`array_diff` must be empty). A service with no specialist links is therefore performable by nobody: every reassign onto it and every slot candidate carrying it is rejected with reason `cannot-perform`. **The condition is inverted relative to the baseline for exactly the unlinked case.**
- **Files:** `frontend/components/BookingScheduleService.php`
- **Verify:** no precondition. Create a `ShopService` with no `user_shop_service` rows, request `/booking/slots?services=<id>` for an otherwise free specialist, and attempt a reassign of a booking carrying it. An empty slot list plus a *"This specialist can't perform that service"* rejection confirms it. **Settle this before reading BE-F01's leg (c).**

**BE-F04 · C-SHP-003 · deviation · built-not-configured · A second status endpoint bypasses the FSM**
- *Twin of CF-CC-01 — the same behaviour recorded independently in two area files. One fix.*
- **B:** the status mutation itself re-checks `allowedNextStatuses`; a transition whose target is not allowed is a no-op with no timestamps, no ledger re-derivation and no notifications.
- **O:** `BookingController::actionTransition` and `actionCollect` both consult the FSM (the latter under a row lock). `AgentsBookingsController::actionUpdateStatus` sets `$model->status` from the request after checking only a cancel-status block and a portal-specific earlier-open-booking rule — it never calls `BookingTransitionService`. `status=3` posted against a SCHEDULED booking is accepted and persisted, and `ensureEarnings` then creates the Payment/Earnings rows; `status=9` is accepted regardless of the no-show grace window.
- **Held at S2 deliberately (carry this):** examined for promotion to S1 and **not promoted**. `update-status` **has no caller in any view or JS** — the only repo reference outside the controller is its own `VerbFilter` entry. Access is `roles: ['@']` plus a `shop_id` equality check, so exercising it requires an authenticated portal user hand-crafting a POST against their own booking. **No authorization hole, no cross-tenant reach, no UI affordance**, and the earnings created are identical to a proper completion.
- **Files:** `frontend/controllers/AgentsBookingsController.php`, `common/components/BookingTransitionService.php`, `frontend/controllers/BookingController.php`
- **Verify:** no precondition. With a valid shop session, POST `id=<a SCHEDULED booking>` and `status=3` to `/agents-bookings/update-status`; separately POST `status=9` for a booking whose start is less than the configured `no_show_grace_min` ago. **Closes BE-F11 (S3) and CF-CC-01 as a side effect.**

**BE-F05 · C-SHP-018 · deviation · n/a · The customer reschedule cap is applied to shop-initiated moves**
- **B:** the reschedule-count limit and its increment are **customer-initiated only**; shop, calendar-drag and group reschedules never touch the counter and are unlimited.
- **O:** both shop-side move paths apply `shop.reschedule_limit` (default 1) as a hard gate and increment `reschedule_count` on every successful move — the code comment reads *"Both move types bump the count"*. **A shop can move a given booking exactly once from the portal or the day calendar**; the second attempt returns *"This booking has reached the maximum number of reschedules allowed."* The same increment fires on a cross-specialist reassign.
- **Files:** `frontend/controllers/BookingController.php`, `frontend/controllers/BookingCalendarController.php`
- **Verify:** no precondition. Reschedule a SCHEDULED booking twice from the portal (and again via a day-calendar drag), reading `booking.reschedule_count` between attempts.

**BE-F06 · C-SHP-022 · gap · built-not-configured · A portal-redeemed promotion is neither attributed nor counted [common/]**
- **B:** when a validated promotion is applied its id is stamped on the booking and its usage count is incremented by exactly one.
- **O:** `WalkInBookingService` builds the `Booking` without assigning `promo_code_id` even when `BookingForm` resolved and priced one, and `PromoCodeService::recordRedemption()` is invoked from a single call site (`api/controllers/BookingController.php:932`) the portal path does not reach. **A promotion redeemed from the portal discounts the booking value but is neither attributed to it nor counted against its cap, so a capped promotion is unbounded from the portal.** `PromoCodeService::validate()` is additionally passed `Yii::$app->user->getId()` — the **shop owner** on this path, not the booking's customer — so the per-customer cap is evaluated against the wrong identity.
- **Files:** `common/services/WalkInBookingService.php`, `api/models/BookingForm.php`, `common/components/PromoCodeService.php`
- **Verify:** no financial precondition. Create a promotion with a total usage cap of 1, redeem it twice through the portal's New walk-in, and read `booking.promo_code_id` and the promotion's usage counter. **Schedule with S1 Group A — the same `WalkInBookingService::create` assignment block.**

**BE-F07 · C-SHP-006 · gap · not-built · `on_hold` earning status is never set, and `earned_full` is stamped at creation [common/]**
- **B:** `earningStatus` becomes `earned_full` on completion and `on_hold` on cancellation or no-show.
- **O:** `Payment::earning_status` is set to `EARNING_STATUS_EARNED_FULL` by `BookingCompletionService` on completion, but **no code path anywhere sets `EARNING_STATUS_ON_HOLD`** on a cancellation or no-show — the constant is referenced only by label/colour helpers. Separately, `WalkInBookingService` stamps `EARNED_FULL` at **creation** time (`buildPayment`), so a walk-in carries `earned_full` while still merely SCHEDULED.
- **Files:** `common/services/BookingCompletionService.php`, `common/services/WalkInBookingService.php`, `common/models/base/Payment.php`, `frontend/controllers/BookingController.php`
- **Verify:** no precondition. Create a walk-in and read `payment.earning_status` before any transition (expect `earned_full`); then cancel a booking and re-read it (expect it unchanged rather than `on_hold`). **Related to F-FIN-07 — both are the cancel/no-show branch not producing the earnings state the settlement rail expects.**

## Collect & complete (3 — CF-CC-04 and CF-CC-08 are in S1 Group A)

**CF-CC-01 · C-SHP-069 · deviation · built-not-working · A booking with money owed can be completed through a second endpoint**
- *Twin of BE-F04 — same endpoint, same fix. Listed in both areas as the source records it.*
- **B:** a booking with an outstanding balance above 0.005 cannot be completed; the refusal is **authoritative** and must hold for direct API/service calls, not only for the UI affordance.
- **O:** the gate is enforced in `BookingController.php:256-263` and on the party path, but `AgentsBookingsController.php:319-405` sets `$model->status = STATUS_COMPLETED`, stamps `end_time` and creates earnings **with no outstanding-balance check and no `allowedNextStatuses` check.** After that the balance is no longer collectable through the collect flow, because the booking is terminal.
- **Held at S2 for the reasons stated under BE-F04.**
- **Files:** `frontend/controllers/AgentsBookingsController.php`, `frontend/controllers/BookingController.php`
- **Verify:** as BE-F04, using a booking that has an outstanding balance.

**CF-CC-03 · C-SHP-073 · deviation · built-not-working · The collect amount is a client field and completion is unconditional**
- **B:** the collect flow collects the **full outstanding balance** (not a user-entered partial) and then runs the completion transition, so the collect-to-complete gate is satisfied by construction.
- **O:** `BookingController.php:376-396` takes the posted `amount`, defaults it to the outstanding only when it is `<= 0`, caps it with `min(amount, outstanding)`, adds it to `in_store_collected`, then sets `status = STATUS_COMPLETED`, `end_time` and `completed_at` **unconditionally, with no re-computation of the balance after the collection.** A POST of `amount=1` against a booking owing 460 records **1.00** and completes the booking with **459.00** still outstanding — bypassing the gate the same controller enforces at `:256`. **The amount is a hidden client field** (`frontend/views/booking-calendar/_detail.php:454`), so it is attacker-controlled.
- **Files:** `frontend/controllers/BookingController.php`, `frontend/views/booking-calendar/_detail.php`
- **Verify:** no financial precondition. POST `amount=1` to `/booking/collect` for a booking with a large outstanding balance and read the resulting status and `in_store_collected`. **Unlike CF-CC-01, this endpoint has a live UI caller, which is why the hidden-field exposure matters.**

**CF-CC-06 · C-SHP-074 · gap · not-built · No entitlement check gates an offered payment timing [common/] [→ ADMIN SE-06]**
- **B:** a payment timing is offered only when the shop's stored toggle is on **AND** the plan entitles the gating feature (online → `mada_apple_pay`; deposit and on_visit → `deposits_pos`); a stale stored toggle never re-enables an unentitled method.
- **O:** `WalkInOptionsService::timings()` (`:199-228`) offers a timing purely on `ShopPaymentSettings::getEnabledModes()`. The keys `mada_apple_pay` and `deposits_pos` appear **only** in `EntitlementService`'s catalogue lists and are read by no payment-method code in `common/` or `frontend/`.
- **Files:** `frontend/components/WalkInOptionsService.php`, `common/components/EntitlementService.php`, `common/models/ShopPaymentSettings.php`
- **Verify:** no financial precondition. On a shop whose plan lacks `deposits_pos`, enable `pay_deposit_enabled` and open the walk-in payment picker. **Same resolver as SN-04 — one fix covers both surfaces.**

## Settings & notifications (3)

**SN-01 · C-SHP-078 · gap · built-not-working · A plan's bundled message allowance is not read [common/] [→ ADMIN NOTIF-02, S1 there]**
- **B:** the free monthly allowance for a paid channel is the shop's **plan** bundle when the plan carries one, otherwise the platform default. The same function feeds the billing gate and the shop-facing usage display.
- **O:** `NotificationDispatchService::freeLimitFor(string $channel)` takes no shop or plan argument and resolves only `CommercialConfig.sms_free_month` / `wa_free_month`, falling back to the `ShopNotificationSetting` constants. `SettingsController`'s usage tiles resolve the same way. `navagoo_subscription_plan.sms_included` / `wa_included` exist, are documented, are editable in the backend plan editor and are stored — **but a repo-wide search shows no reader outside the admin form and the migration.** A shop on a plan bundling 200 SMS is billed **from message 51** instead of message 201, and its Settings tile shows the wrong denominator.
- **Files:** `common/components/NotificationDispatchService.php`, `frontend/controllers/SettingsController.php`, `common/models/NavagooSubscriptionPlan.php`
- **Verify — preconditions P-PLANBUNDLE, P-SMSPRICE.** Set the plan bundle to 200 against a global of 50, then send SMS-channel notifications past 50. Confirmed if the usage tile's denominator stays at the global default and an `sms` `Charge` row appears on send 51. **The wrong denominator is visible without P-SMSPRICE; the charge is not.**

**SN-02 · C-SHP-075 · gap · built-not-working · `shop.time_format` has no consumer outside its own picker**
- **B:** each shop chooses 12h or 24h and that choice drives **every** shop-facing time rendering: the day-calendar gutter and cards, the bookings list, booking detail/timeline, slot-picker labels and its immediate-booking label, time-off pickers, and the time token inside notification bodies.
- **O:** `shop.time_format` persists correctly but has no consumer beyond the Settings ▸ Scheduling pane's own open/close pickers. `BookingCalendarPresenter::minuteLabel` and `BookingScheduleService::minuteLabel` use `date('g:i A')`; `IdGeneratorHelper::formatTime()` returns `'H:i'`; `MoneyHelper::timeShort()` / `time12()` are fixed helpers with no shop argument. **Switching a shop to 24-hour leaves the calendar, slot picker and booking rows unchanged.** Secondary: the column defaults to `0` = 24h where the baseline defaults to 12h when unset.
- **Files:** `frontend/views/settings/_scheduling.php`, `frontend/components/BookingCalendarPresenter.php`, `frontend/components/BookingScheduleService.php`, `common/helpers/IdGeneratorHelper.php`, `common/helpers/MoneyHelper.php`
- **Verify:** no precondition. Flip a shop to 24-hour, then sweep the day-calendar gutter and cards, the bookings list, the booking detail timeline, the slot picker and the time-off editor. Every surface still rendering `h:mm AM/PM` confirms it; **a surface that does change narrows the finding to the specific renderers named.**
- **Related:** SN-06 (S3) is the same absence inside notification bodies, and F-SHP-UI-15 / F-SHP-UI-27 (Track U) are its rendering symptoms. One time-formatting decision covers all four.

**SN-04 · C-SHP-082 · gap · not-built · Settings ▸ Payments has no entitlement state [→ ADMIN SE-06]**
- **B:** plan entitlement gates shop surfaces; deposits/POS is one of the mockup's live-gated features, and Settings ▸ Payments carries an **entitled** state (toggles) versus a **locked** state (upsell rows linking to Plans).
- **O:** `frontend/views/settings/_payments.php` contains no reference to `EntitlementService`, any feature key, or a locked/upsell state; the pay-online / deposit / pay-on-visit rows and the deposit-percentage field render unconditionally for every shop. `ShopPaymentSettings` validates only the platform deposit cap. `EntitlementFilter::FEATURE_MAP` does not list the settings controller, and `deposits_pos` appears nowhere outside `EntitlementService`'s catalogue constant.
- **Files:** `frontend/views/settings/_payments.php`, `common/models/ShopPaymentSettings.php`, `frontend/components/EntitlementFilter.php`, `common/components/EntitlementService.php`
- **Verify:** no precondition. Open Settings ▸ Payments on a shop whose plan lacks `deposits_pos`. **The locked-state component already exists and works — the plan-gate interstitial was verified live on `/branch` (H5). This surface simply does not use it.**

## Classification (2)

**CLS-01 · C-SHP-044 · gap · not-built · The portal has no deep-link booking source [common/]**
- **B:** `viaDeepLink` is a first-class booking source: a customer arriving through the shop's own link books with `source='deep_link'` and is classified `shop_owned/deep_link`, **ahead of the freeze list and ahead of the app branch.** The exclusion list scopes out the demo's deep-link chip but **explicitly keeps deep_link attribution on bookings in scope.**
- **O:** `classifyOnFirstBooking()` (`:181-186`) computes `viaDeepLink` only when the booking method is MOBILE, and derives it from a **proxy** — `invitedViaDeepLink()` (`:221-234`) returns true only when this shop previously sent that phone number a customer-invitation message with `delivery_status = success`. A customer who opens the shop's deep link (`CustomersController.php:184-196` generates `/book/{token}`) and books in the app, without ever having been on one of that shop's invitation campaigns, resolves to `navagoo_sourced/app_first_booking` — **i.e. a marketing fee is raised where the baseline raises none.**
- **Documented dependency (carry this):** the service docblock records this as a deferred item **requiring the api signup flow to persist the invite token at account creation.** It is not closable inside the portal alone.
- **Files:** `common/components/CustomerClassificationService.php`, `frontend/controllers/CustomersController.php`
- **Verify — precondition P-RATES (to see the fee).** Open the shop's deep link as a brand-new customer who has never appeared on any of that shop's campaign recipient lists, complete a booking, and read the classification row's `classification` and `source`.

**CLS-02 · C-SHP-045 · deviation · built-not-working · Classification is written by whichever later event runs first [common/]**
- **B:** a classification row is created on the customer's **first booking** at that shop, recording classification, reason and date, evaluated against the inputs **as they stand at that booking** (`store.ts:3238-3268`).
- **O:** only two creation paths stamp at booking time — shop walk-ins (`WalkInBookingService.php:225`) and group creation (`GroupBookingService.php:390`). **A solo/app booking is classified neither at creation nor at completion**: `ensureEarnings()` calls only `ensureProcessingFee()`, and `deriveBookingCharges()` has five call sites, none of them a completion. The row is therefore first written by whichever later event runs first — a cancellation, an admin override, the demo billing controller, or **a shop opening the earnings detail page**, since `frontend/views/earnings/view.php:125` calls `resolveClassification()` during rendering, so **a GET request persists the row.** Because `classifyOnFirstBooking()` re-reads `CustomerFreeze::frozenMobiles()` and the invitation proxy at that moment, **a freeze-list entry added after the first booking can change the stamped outcome**, and a booking that never reaches one of those events is never classified at all.
- **Files:** `common/services/BookingCompletionService.php`, `common/components/FinanceLedgerService.php`, `common/components/CustomerClassificationService.php`, `frontend/views/earnings/view.php`, `frontend/controllers/BookingController.php`
- **Verify:** no rate precondition. Create an app booking for a customer with no prior history, query `customer_classification` for `(shop_id, customer_id)` immediately after creation and again after completion, then open Finance ▸ Earnings, click that booking's row, and query a third time. Confirmed by no row after creation and after completion, and a row appearing only after the earnings detail page is rendered.
- **Second probe, for the late-evaluation half:** add the customer's mobile to the freeze list between the app booking and the first writing event; a `source` of `freeze_list` rather than `app_first_booking` proves the inputs are read at the write moment, not at first-booking time.
- **Schedule with S1 Group B** — the same missing call at the completion chokepoint.

## Group bookings (2)

**GRP-04 · C-SHP-059 · deviation · built-not-working · Party actions perform transitions the solo path rejects [common/]**
- **B:** whole-party start / cancel / complete each apply the **ordinary per-booking transition** to every child (`store.ts:4053-4065`, `:4085-4098`), so the status FSM decides what may move. A scheduled child cannot be completed by *Complete all*, and an in_progress child cannot be cancelled by *Cancel all*; such children are simply left alone.
- **O:** `GroupBookingService::startGroup()` (`:578-595`), `cancelGroup()` (`:607-645`), `cancelParticipant()` (`:674-727`) and `completeGroup()` (`:854-887`) assign `Booking::STATUS_*` directly and **save with validation off**, never calling `BookingTransitionService`. `completeGroup()`'s `$completable` list is `[SCHEDULED, INPROGRESS, ACCEPTED]`, so a **scheduled** guest is stamped `STATUS_COMPLETED` + `completed_at` and pushed through `ensureEarnings()` without ever passing through `in_progress`; the cancel paths cancel any non-terminal child, **including INPROGRESS**. The portal's own canonical map (`defaultTransitions()`, `:44-54`) forbids both moves.
- **Files:** `common/components/GroupBookingService.php`, `common/components/BookingTransitionService.php`, `frontend/controllers/AgentsBookingsController.php`
- **Verify:** no financial precondition. Create a 3-guest party, collect in full, leave all children SCHEDULED (never press *Start all*), press *Complete all*, and record each child's status, `completed_at` and whether Earnings/Payment rows were created. Confirmed if SCHEDULED children move straight to COMPLETED.
- **Note:** the sibling candidate GRP-07 was **refuted** — the mockup does not complete a party partially when money is owed either, so *"the first press completes nobody"* is parity, not a defect. Do not re-raise it from the same probe.

**GRP-06 · C-SHP-058 · deviation · built-not-working · `balance_due` is written once at creation and never updated [common/]**
- **B:** a guest's outstanding balance is derived **at read time** as `max(0, bookingValue − amountCollected − inStoreCollected − refundValue)`. After the party's balance is collected, every guest reads as settled and the party shows nothing outstanding.
- **O:** `GroupBookingService::outstandingBalance()` (`:914-921`) returns the stored `balance_due` column whenever it is non-null, and `_group_drawer.php:73-74` does the same per guest. **`balance_due` is written exactly once, at party creation (`:361`), and no code updates it afterwards** — `collectGroup()` writes only `in_store_collected` / `in_store_method` / `collected_at`, and cancellations write only `refund_value`. After a successful *Collect all*, `summariseGroup()['outstanding']` and each guest row still show the original amount due, and the drawer's *Collect all* button (rendered when `$outstanding > 0.005`) never clears.
- **The completion gate is unaffected** because `completeGroup()` uses the correct `BookingFinanceMath` formula — **so the two views of the same party disagree.**
- **Files:** `common/components/GroupBookingService.php`, `frontend/views/agents-bookings/_group_drawer.php`, `frontend/components/BookingFinanceMath.php`
- **Verify:** no financial precondition. Create a 2-guest pay-on-visit party, run *Collect all* (card, with a tip), reload and re-expand the drawer. Confirmed if `in_store_collected` is raised per child while `balance_due` and the drawer's outstanding figure stay at their creation-time values and the *Collect all* affordance is still visible.

---

# S3 — Moderate

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

## Booking engine (6)

**BE-F08 · C-SHP-015 · deviation · n/a · The immediate-booking affordance is the first grid slot**
- **B:** exactly one candidate — the reference time on the **current** business day rounded up to the next slot step — offered only if the full placement check passes for the whole duration from that point, and suppressed from the grid when it duplicates the first slot.
- **O:** `actionSlots` returns `bookNow = $slots[0]`. It is the earliest open slot rather than the rounded boundary from now, is returned for **any** requested day rather than today only, is never independently placement-checked, never becomes unavailable while any slot exists, and — because it **is** `slots[0]` — the grid necessarily repeats it.
- **Files:** `frontend/controllers/BookingController.php` · **Verify:** no precondition. Request slots for a future day and check whether `bookNow` is returned.
- **Related Track U item:** F-SHP-UI-04 — `Book now` and the `Earliest` badge do not render at all inside the New-booking modal, though they do in Reschedule.

**BE-F09 · C-SHP-013 · deviation · n/a · A deactivated specialist renders as an ordinary calendar column**
- **B:** day-view columns are every **active** specialist who works that business day rendered normally, plus any other specialist still holding a non-cancelled booking rendered **muted**.
- **O:** `calendarColumns` queries agents by `user_type` and `shop_id` **with no status predicate**, so a deactivated specialist who still has working hours for that weekday renders as an ordinary, un-muted, droppable column. **The muted branch itself is correct** — muted columns are ordered last and the day-grid JS rejects them as drop targets via `data-muted="1"`. Sibling queries elsewhere (`AgentsController.php:114, :795`) do apply `status = User::STATUS_ACTIVE`, so **the omission is local to this method.**
- **Files:** `frontend/components/BookingScheduleService.php`, `frontend/web/js/booking-calendar.js` · **Verify:** no precondition. Deactivate a specialist who has working hours today and reload the day view.

**BE-F11 · C-SHP-006 · deviation · built-not-working · `completed_at` is null on the second completion path**
- **B:** on a successful transition the booking gains exactly the timestamp matching the new status — completed maps to `completedAt`.
- **O:** `BookingController::actionTransition` and `actionCollect` both stamp `completed_at` (and `end_time`). `AgentsBookingsController::actionUpdateStatus` writes **only `end_time`** on the COMPLETED branch, so a booking completed from that panel is persisted with `completed_at` null, and downstream consumers keyed on the terminal timestamp (settlement transaction date, timeline rendering) fall back rather than reading the real completion instant.
- **Files:** `frontend/controllers/AgentsBookingsController.php`, `frontend/controllers/BookingController.php` · **Verify:** no precondition. **Closed by BE-F04's fix.**

**BE-F12 · C-SHP-007 · deviation · built-not-working · A reschedule validates the stored span, not the current service sum**
- **B:** a booking's scheduled length is always the sum of its services' durations, computed at read time; **no end timestamp is authoritative.**
- **O:** the grid render and the `checkPlacement` overlap comparison both derive the end from `bookingDuration()` as required, but `moveBooking()` computes the duration it validates from the **stored span** (`Booking::getScheduledDuration()` over `from_hour`/`to_hour`) whenever the client omits an explicit `to`. A reschedule therefore validates the footprint frozen at the last write, and falls back to a **60-minute** block (not the 15-minute floor) when the stored span is unusable.
- **Files:** `frontend/controllers/BookingController.php`, `frontend/components/BookingScheduleService.php`, `common/models/base/Booking.php` · **Verify:** no precondition. Edit a service's duration after a booking is made, then reschedule that booking and compare the validated footprint.

**BE-F13 · C-SHP-022 · gap · built-not-configured · Per-line service prices are not snapshotted on portal-created bookings [common/]**
- **B:** each selected service contributes a line carrying its `serviceId`, name and the service's catalogue price **at booking time**.
- **O:** `booking_service` already carries the snapshot columns (`service_amount`, `service_amount_before`, `discount_value`, `price_excl_vat_after_discount`) but `WalkInBookingService`'s create loop writes only `booking_id` and `service_id`, **leaving all four null.** Per-line pricing can then only be re-read from the live catalogue, which moves when a price is edited. **The booking-level totals ARE snapshotted**, so the aggregate is stable.
- **Files:** `common/services/WalkInBookingService.php`, `common/models/base/BookingService.php` · **Verify:** no precondition. Create a walk-in and read its `booking_service` rows. **Schedule with S1 Group A — same create loop.**

**BE-F14 · C-SHP-004 · deviation · built-not-configured · The reschedule allow-list is config-driven in the UI and hard-coded on the server**
- **B:** rescheduling is gated by its own admin-configurable allow-list (`config.rescheduleStatuses`), and the same gate applies to specialist re-assignment and calendar drag.
- **O:** `BookingTransitionService::canRescheduleStatus()` exists and is used by `BookingDetailViewModel` to decide whether to render the Reschedule action, but the **server-side write guard** in both `moveBooking` implementations is the hard-coded `BookingScheduleService::isDraggable() = [SCHEDULED, ACCEPTED]`. **The two agree at the default configuration**; widening `rescheduleStatuses` would surface the action in the UI while the server continued to reject it.
- **Files:** `frontend/controllers/BookingController.php`, `frontend/controllers/BookingCalendarController.php`, `frontend/components/BookingScheduleService.php`, `common/components/BookingTransitionService.php` · **Verify:** no precondition, but **dormant at the default config** — widen `rescheduleStatuses` in `booking_transition_config` first, or the two gates agree and nothing shows.

## Shop finance (3)

**F-FIN-11 · C-SHP-040 · deviation · built-not-configured · Threshold invoicing has no scheduled pass and no single-open-invoice guard [→ ADMIN F-FIT-09 / F-FIT-12]**
- **B:** automatic issuance fires when `carriedBalance >= effectiveCarryThreshold` **AND** the shop has no open threshold invoice, so exactly one open threshold invoice exists at a time; `dueAt = issue time + config.invoiceDueDays`.
- **O:** `reconcileBilling` is invoked only from `AdminFinanceController::actionShopBalances`, looping every shop with a charge row **when an admin opens that screen** — there is no console or scheduled pass. It tests `carried >= threshold` and `issuable > epsilon` but **never checks whether an open threshold invoice already exists**, so once fresh fees accrue past the threshold a second open threshold invoice is issued. `issueShopInvoice` never assigns `invoice.due_at`, and no invoice-due-days setting is read at issuance.
- **Files:** `backend/controllers/AdminFinanceController.php`, `common/models/base/Invoice.php` · **Verify:** no precondition for the schedule half — read `console/config/schedule.php` and any server crontab. For the guard half, **precondition P-RATES**: accrue fees past the threshold twice and look for two unpaid `type=threshold` rows for one shop.

**F-FIN-13 · C-SHP-043 · deviation · built-not-working · `costsToDate` excludes unpaid card-rail rows [common/]**
- **B:** `costsToDate` for a shop = `Σ chargeAmount + vatAmount` over rows that are **not paid and not pending**, including negative reversed rows.
- **O:** `costsToDate` uses `ChargeQuery::outstanding()`, which additionally restricts to `settlement_method = net_from_settlement`, **so an unpaid `charge_to_card` (subscription) row never reaches the shop's costs-to-date figure.** The codebase carries a `nonPending()` scope documented as the demo-equivalent filter, but `costsToDate` does not use it. There is also no single `outstandingChargeTotals` helper returning separate charge and VAT totals with optional transfer/invoice exclusions, and `oldestUnpaidFeeAgeDays` exists only as a protected admin-controller method returning null rather than 0 when there are no unpaid fees.
- **Files:** `common/components/FinanceLedgerService.php`, `common/models/query/ChargeQuery.php`, `backend/controllers/AdminFinanceController.php` · **Verify:** no rate precondition — subscription charges carry real amounts on staging. Leave an unpaid `charge_to_card` row and read the shop's costs-owed figure.

**F-FIN-15 · C-SHP-041 · deviation · built-not-configured · Three different withdrawal-minimum fallbacks [common/]**
- **B:** `meetsWithdrawalMin = withdrawable >= shop.minWithdrawalAmount` — **one** configured floor per shop.
- **O:** three fallbacks are used when the shop has no `minimum_withdrawal_amount`: `WithdrawalBundlingService::DEFAULT_MIN_WITHDRAWAL = 5000`, `EarningsController::actionIndex` hardcodes `5000`, and `FinanceLedgerService::shopBalanceView` falls back to `50.0`. **The dashboard balance panel and the Earnings tab can therefore disagree about whether the same shop meets its minimum.** `FinanceLedgerService::minWithdrawal` also reads `CommercialConfig.min_withdrawal_sar`, which the bundling service does not consult at all.
- **Files:** `common/components/WithdrawalBundlingService.php`, `common/components/FinanceLedgerService.php`, `frontend/controllers/EarningsController.php` · **Verify:** no precondition. Null a shop's `minimum_withdrawal_amount` and compare the dashboard panel's hint against the Earnings tab's. **Related:** `BACKLOG_ADMIN.md` CF-CC-05 (globals as a live fallback rather than copied at shop creation).

## Settings & notifications (4)

**SN-03 · C-SHP-082 · deviation · built-not-configured · A shop with no subscription passes every feature gate [common/] [→ ADMIN SE-05]**
- **B:** a shop's usable features are the feature list stamped on its subscription term — **there is no baseline set**: a shop with no access-granting subscription gets an **empty** feature set.
- **O:** `EntitlementService::shopCanAccess(int $shopId, string $feature, bool $defaultAllow = true)` returns `$defaultAllow` when `ShopSubscription::findCurrentForShop()` finds no row. **The docblock records this as a deliberate live-rollout posture** ("existing shops that predate billing aren't locked out… Flip `$defaultAllow` to false to enforce hard gating later"), and `EntitlementFilter`'s separate `GO_LIVE_MAP` still blocks the booking calendar and walk-in creation for such shops. **The divergence is in scope and intentional, not accidental — a product decision, not an engineering defect.**
- **Files:** `common/components/EntitlementService.php`, `frontend/components/EntitlementFilter.php` · **Verify — precondition P-NOSUB.** Sign in as a shop with no `shop_subscription` row and open the gated surfaces, then repeat with a cancelled subscription whose term has passed. **This half of H5 could not be verified end to end — no such login was available.**

**SN-05 · C-SHP-077 · deviation · built-not-working · Approval is per-trigger, not per-channel [common/] [→ ADMIN NOTIF-03]**
- **B:** a channel can only fire if the platform enabled it **AND** its template is approved; **approval is per channel**, so SMS may be approved while WhatsApp is still pending.
- **O:** approval is a single per-trigger flag. `channelUsable()` checks `$trigger->approval_status` for **every** channel including in-app, and the settings pane's `$usable` / `$pending` closures use `channelEnabled($ch) && isApproved()`. **Approving one paid channel approves the other and the in-app channel with it.** Migration `m260728_100100` already added `sms_approval_status` / `wa_approval_status` (backfilled from `approval_status`) and **states in its own docblock that wiring the code to them is a deferred follow-up**; no application code reads either column.
- **Files:** `common/components/NotificationDispatchService.php`, `frontend/views/settings/_notifications.php`, `common/models/NotificationTrigger.php`, `common/migrations/db/m260728_100100_add_notification_per_channel_approval.php` · **Verify:** no financial precondition. Set `sms_approval_status = 0` while leaving `approval_status = 1` (and the reverse) on a trigger with both templates, then reload Settings ▸ Notifications. Both paid cells moving together confirms it.

**SN-06 · C-SHP-081 · deviation · built-not-working · The appointment-date-time token is raw DB columns [common/]**
- **B:** the `[appointmentDateTime]` token resolves to the formatted date plus the time **in the shop's chosen format** (C-SHP-075).
- **O:** `buildVars` builds the token as `trim(booking_date . ' ' . from_hour)` — the raw DB columns concatenated, **with no date formatting, no localisation and no reference to `shop.time_format`.** The rendered customer message reads e.g. `2026-05-20 10:00`, and **the same raw shape reaches both the EN and AR bodies.**
- **Files:** `common/components/NotificationDispatchService.php` · **Verify:** no precondition. Render any trigger carrying the token. **Same time-formatting decision as SN-02 — schedule together.**

**SN-08 · C-SHP-082 · deviation · built-not-working · Entitlements are read live from the plan, with a tier-default fallback [common/]**
- **B:** a shop's usable features are the feature list **stamped on its subscription term**.
- **O:** `EntitlementService::shopFeatures()` reads `$sub->plan->features` **live from the plan row**, so editing a plan retroactively changes the entitlements of existing subscriptions. It then intersects the stamped list against the canonical catalogue and, when nothing canonical survives — live plan rows stamp display labels such as *"Online booking & real-time calendar"* — **falls back to `featuresForTier($plan->tier)`, granting the tier's full default set rather than whatever the term actually stamped.** The fallback is documented in-code as protection against label-only plan data.
- **Files:** `common/components/EntitlementService.php` · **Verify:** no precondition. Stamp a display label on a plan and confirm `shopFeatures()` falls back to the tier default. **Directly parallel to `BACKLOG_ADMIN.md` H5.3 and SE-14 — the same fallback masks all three, and the admin plan feature matrix is 0 of 57 populated.**

## Classification (2)

**CLS-03 · C-SHP-045 · deviation · built-not-configured · The classification row is keyed by customer id, not mobile, on the money-gating reads [common/]**
- **B:** the classification row is keyed by the customer's **mobile number** plus the shop id, not by a customer row id, so attribution follows the person rather than the account record.
- **O:** the table is keyed `(shop_id, customer_id)` with the unique constraint on that pair; `mobile` is an added column consulted **only** by `findRow()`, reached from `classifyCustomer()` / `classifyOnFirstBooking()`. **The read paths that gate money do not use it:** `classificationFor()` (`:241-245`) and `isChargeable()` (`:248-251`) query `findOne(['shop_id','customer_id'])` with **no mobile fallback**, `adminOverride()` does the same and stamps no mobile on a row it creates, and the shop Customers screen joins by `customer_id`. **A customer who re-registers under a new User id resolves to the `shop_owned` default on those paths — including the package-fee decisions that call `classificationFor()` — while `classifyOnFirstBooking()` would have found the original row.**
- **Files:** `common/components/CustomerClassificationService.php`, `common/models/base/CustomerClassification.php`, `frontend/controllers/CustomersController.php` · **Verify:** no rate precondition. Create a classification row, then re-register that customer under the same mobile with a new User id, and compare what the Customers screen shows against what `classificationFor()` / `isChargeable()` return for the new id.

**CLS-04 · C-SHP-047 · gap · not-built · There is no beneficiary / booked-by concept [common/]**
- **B:** when a booking is made for someone else, the beneficiary is resolved or created **by normalised mobile** (existing contact reused, never duplicated), `booking.customerId` is the **beneficiary**, the booker is stored separately as `bookedByCustomerId`, and classification/attribution, promotion caps and first-service checks all key on the beneficiary.
- **O:** the `Booking` model exposes no field for the booker, and a repo-wide search for `booked_by` / `bookedBy` / `beneficiary` / `on_behalf` / `booking_for` across `common`, `frontend` and `api` returns **no match**. Group bookings stamp every child with the organiser's `customer_id` and carry guests as `guest_label` strings — **which matches the baseline's group rule** but provides no beneficiary concept. There is consequently no path on which classification could key on a beneficiary distinct from the booker.
- **Files:** `common/models/base/Booking.php`, `common/components/GroupBookingService.php` · **Verify:** inspect the live api booking-create contract for any beneficiary / on-behalf field and the mobile client's book flow for a "booking for someone else" step. **If the app has no such step, this is a data-model-only gap with no reachable flow — grade accordingly before scheduling.**

## Group bookings (1)

**GRP-02 · C-SHP-054 · gap · not-built · A group child and an equivalent standalone booking do not derive identical charges [common/]**
- **B:** a group child and an equivalent standalone booking derive **identical** ledger rows at the same lifecycle point — both run `deriveBookingCharges` at creation, producing the marketing row (pending while scheduled) and, when money was collected online, the processing row.
- **O:** **group children match the baseline** (`GroupBookingService.php:390`). The standalone side does not: `WalkInBookingService` calls only `classifyOnFirstBooking` and creates **no** `Charge` rows, and a completed standalone booking picks up only a processing fee via `ensureEarnings → ensureProcessingFee`. A field-by-field diff therefore diverges — the standalone booking has no `marketing_fee` row.
- **The corrective work sits on the standalone/finance path, not in the group service.** Closed by S1 Group B.
- **Files:** `common/components/GroupBookingService.php`, `common/services/WalkInBookingService.php`, `common/components/FinanceLedgerService.php`, `common/services/BookingCompletionService.php` · **Verify — precondition P-RATES.** Create a 1-guest party paid online and an equivalent standalone walk-in with the same service, value and timing for a navagoo-sourced customer, then diff the `charge` rows immediately after creation and again after completion.

## Collect & complete (1)

**CF-CC-07 · C-SHP-074 · deviation · built-not-working · The walk-in payment toggles are rendered but never persisted [common/] [→ ADMIN SE-07]**
- **B:** walk-in bookings read a **separate** toggle triple (`walkinOnline`, `walkinDeposit`, `walkinOnVisit`), so a shop can accept online payment from the app while offering only pay-on-visit at the counter.
- **O:** the three columns exist (`m260628_200000_add_shop_walkin_time_fields.php`) and are rendered as checkboxes in the payments settings form (`_payments.php:94, :149, :160`), **but `ShopPaymentSettings::scenarios()` lists only `pay_on_visit_enabled`, `pay_deposit_enabled`, `pay_online_enabled` and `deposit_percentage` as safe attributes**, so `SettingsController.php:327`'s `$paymentModel->load($post)` silently drops the three walk-in values and **the checkboxes never persist.** Independently, `WalkInOptionsService::timings()` reads `getEnabledModes()`, built from the **app** toggles only, so the walk-in triple would have no effect even if it were saved.
- **Files:** `common/models/ShopPaymentSettings.php`, `frontend/controllers/SettingsController.php`, `frontend/views/settings/_payments.php`, `frontend/components/WalkInOptionsService.php` · **Verify:** no precondition. Toggle the three checkboxes to the opposite of their current values, save, re-read `shop_payment_settings`. Unchanged `walkin_*` values confirm it. **Two defects, one item: the mass-assignment drop and the reader that would ignore them anyway. Fixing only the first produces a saved value that still does nothing — the §9.4 pattern.**

---

# S4 — Minor

5 items.

**BE-F15 · C-SHP-016 · deviation · n/a · booking-engine · The next-available-day lookahead is 60 days, not 56, and the second scan is absent**
- **B:** the next-available-day lookahead is bounded at **56** days, and the same bound applies to locating the earliest bookable day from today.
- **O:** `actionSlots` scans forward to `strtotime(today) + 60 * 86400` — bounded, but at **60** days. Only one scan exists (next open day after the requested day); **the baseline's second scan, which locates the earliest bookable day from today so its first slot can be badged `Earliest`, has no portal equivalent.**
- **Files:** `frontend/controllers/BookingController.php` · **Verify:** no precondition. **The missing second scan is the data half of F-SHP-UI-04 (Track U) — schedule together.**

**F-FIN-14 · C-SHP-036 · deviation · built-not-working · shop-finance · The notification charge's basis column holds the unit price, not the message count [common/]**
- **B:** a billed customer SMS/WhatsApp raises a row with `basisAmount` = the **message count**.
- **O:** `NotificationDispatchService` writes `base_amount` = the **per-message unit price** (the same value it puts in `fixed_snapshot` and `total_amount`); the message count lives only in `meta.count`. **Every other field on the row matches the baseline, and because one row is written per message the charge amount is unaffected** — the divergence is in what the basis column means when the ledger is read or exported.
- **Files:** `common/components/NotificationDispatchService.php` · **Verify — preconditions P-SMSPRICE, P-NOTIFCHG.** Send one billable message and read `charge.base_amount` against `meta.count`.

**CLS-05 · C-SHP-044 · deviation · built-not-configured · classification · The walk-in reason identifier is `walk_in`, not `shop_admin_walkin` [common/]**
- **B:** branches 1 and 5 of the classification walk record reason `shop_admin_walkin`; the other three record `deep_link`, `freeze_list`, `app_first_booking`.
- **O:** the portal stores `walk_in` for both branch 1 and the fallthrough; **the other three constants match the baseline verbatim.** Semantics and the two-outcome mapping are identical and the shop UI renders it as *Walk-in*, **so this is an identifier rename rather than a behavioural difference** — recorded only because the contract's method makes the recorded reason part of the assertion.
- **Files:** `common/models/base/CustomerClassification.php`, `common/components/CustomerClassificationService.php` · **Verify:** no precondition. **A rename touches stored rows — decide whether it is worth a data migration before scheduling.**

**GRP-01 · C-SHP-050 · deviation · built-not-working · group-bookings · A failed party creation leaves the new organiser persisted [common/]**
- **B:** on the first guest failure the whole party creation aborts and **nothing** is written — explicitly including no customer row (the mockup commits customers only in the single terminal `set()`).
- **O:** in `createGroupBooking` the organiser is resolved at `:195-204`, and the new-organiser path (`resolveNewOrganiser()`, `:981-1014`) saves a new `User` row immediately. **The transaction is not opened until `:229`**, so a subsequent rejection — duplicate specialist, cross-shop service id, placement failure, or the max-group-size cap — rolls back bookings, booking services, classification and charges but **leaves the newly created customer persisted.** Repeated failed attempts with the same mobile reuse that row rather than duplicating it, but the row exists with no booking attached.
- **Files:** `common/components/GroupBookingService.php` · **Verify:** no precondition. POST to `agents-bookings/create-group` with a brand-new organiser and one deliberately unplaceable guest, then query the `user` table for that mobile.

**SN-09 · C-SHP-077 · deviation · built-not-working · settings-notifications · A shop-audience trigger is gated on approval where the baseline sends unconditionally [common/]**
- **B:** shop-audience triggers always send **in-app only** — the mockup returns `['in_app']` unconditionally for `audience === 'shop'`.
- **O:** `resolveChannels()` gates the shop-audience path on `channelUsable()`, and `dispatchShopEvent()` does the same, so a shop-audience trigger with `approval_status = 0` or no authored in-app template **emits nothing.**
- **Effect depends on data (carry this):** the seeded triggers were approved for in-app by migration `m260721_215500`, **so the divergence only surfaces for a newly created or re-pended shop trigger. A probe against the seeded set will show nothing.**
- **Files:** `common/components/NotificationDispatchService.php` · **Verify:** create a new shop-audience trigger, leave it pending, and fire its event. **Same gate as SN-05 and as `BACKLOG_ADMIN.md` NOTIF-01 — one `channelUsable()` correction covers all three.**

---

# Outside the area scorecard

Three items from `_hypotheses.md`. They are real, evidenced findings; they are not counted in the
report's per-area scorecard, which indexes the seven contract areas only. **Two of the three are not
portal-backlog work** — read the routing note on each before scheduling.

**H3 · S3 · gap · not-built · The specialist editor has no bilingual fields and no translate affordance**
- **B:** the mockup's *Edit specialist* dialog exposes three bilingual pairs — **Name**, **Title**, **Bio** — each rendered as two side-by-side controls (`ENGLISH` / `العربية`), each field group carrying the hint *"fill one — the other auto-translates"* and **each side** its own translate button (6 in the dialog, counted in the DOM).
- **O:** `/agents/update` exposes one `UserForm[full_name]` input, **no Title field at all** (the card label falls back to the literal "Specialist", reading the single-value `user.role` column the shop editor does not expose), and one `UserForm[bio]` textarea. A DOM enumeration returned **zero** `data-translate-pair` / `data-translate-lang` attributes and **zero** `_en`/`_ar` inputs — the two wiring conventions `aurora-translate.js` recognises. **The script is loaded on the page; it simply finds nothing to bind to.** The system is live elsewhere: `/shop-service/update` renders Service name as an AR/EN pair with the same hint and button.
- **Why this is not a view edit (carry this):** `user.full_name` and `user.role` are single `string` columns and `user_profile.bio` was added as a single `TEXT` column. Adding the affordance requires new `_ar` columns **plus a decision on how the mobile client reads them.** Per `CLAUDE.md` the `api/` tier is shared with a separate mobile app, so this is a **schema + API-contract change.**
- **Routing:** **recommend the API/mobile owner, not the portal backlog.** It also sits against the repo-wide bilingual requirement in `CLAUDE.md`, so a reviewer may rate it higher than S3.
- **Files:** `frontend/views/agents/_form.php`, `frontend/views/agents/index.php:136`, `frontend/web/js/aurora-translate.js`, `common/models/User.php:343`, `common/migrations/db/m231016_112956_add_bio_userProfile_table.php:15`
- **Verify:** no precondition. Count translate buttons and `_ar`-named inputs on the specialist form.

**H4 · S3 · gap · not-built · The specialist image picker has no crop, zoom or app-shape preview**
- **B:** from one `input[type=file][accept=image/*]`: a 144px framed preview tile with inline remove, a live **"IN THE CUSTOMER APP"** strip showing the image in both shapes the app uses (circle and rounded square), a **Change photo** button, and the caption *"Drag & zoom to frame it…"*. Selecting a file opens an **Adjust image** modal — drag-to-reposition canvas, circular app-crop overlay, **Zoom** slider, **Reset**, **Cancel / Apply**. **Verified by actually uploading a file, not read from static markup.**
- **O:** the portal uses the `trntv/filekit` `Upload` widget bound to `UserProfile[picture]`. Selecting the same 400×400 test file produced an **immediate AJAX upload** (`POST /agents/avatar-upload` → 200) and a thumbnail with a remove overlay. **No crop, zoom, reposition, reset, confirm/apply step, or app-shape preview**, and the file is committed to temp storage on selection rather than on Apply.
- **Routing:** **pure front-end; no data-model or API dependency.** The stored artefact is a single image path either way, so a cropper can be added client-side without touching the contract. Practical impact: a shop owner cannot control the framing of a face in the circular avatar the customer app uses.
- **Files:** `frontend/views/agents/_form.php:717-738`, `frontend/controllers/AgentsController.php` (`avatar-upload`)
- **Verify:** no precondition. Upload an image on the specialist form and look for an Apply gate.

**H6 · S2 · deviation · built-not-configured · Subscription checkout does not reach a live payment rail — environment, not build**
- **B (for the screen, not the rail):** the mockup's *Upgrade to Pro* dialog offers **Saved card** and **Bank transfer** and completes in-dialog with no gateway step. **The mockup has no backend, so it neither demonstrates nor requires a live rail; the portal's structure matches it.**
- **O:** three independent runtime observations on staging — the plans page ships `paymobTokenizationConfigured = false`; `POST /earnings/card-token-start` returns `{"success":false,"message":"Card tokenization is not available right now."}` (the early return at `EarningsController.php:937`, i.e. at least one of `PAYMOB_API_KEY`, `PAYMOB_CARD_INTEGRATION_ID`, `PAYMOB_IFRAME_ID` is unset); and the saved card carries a locally generated placeholder (`$card->token = 'tok_' . bin2hex(random_bytes(12))`) with no `gateway` or `gateway_token`. `recordSubscriptionCharge()` takes the live rail only when **all four** conditions hold, so the executing branch is the documented dev fallback — *"no Paymob config / no real saved token yet — dev fallback, instant simulated PAID"*.
- **Stated as an inference (carry this):** reaching `recordSubscriptionCharge()` with a non-zero amount requires ending the shop's live trial (`trial_consumed` is already 1, so a fresh subscribe would stamp an irreversible PAID row) and no second shop-portal login existed. **The branch is established from code plus three runtime probes, not from a completed charge.** The observed Upgrade-to-Pro run took the free-trial branch — a plan swap with no charge — so it exercised the checkout UX, not the money path.
- **Severity note:** **S2 as an environment/readiness item, not a build defect.** The integration exists, is shaped for the real flow (auth → order → payment key → pay-with-token) and is correctly gated so an unconfigured environment degrades instead of erroring. **What is missing is staging configuration.**
- **Residual product risk worth a decision:** with the fallback active a subscription reads **PAID** on the ledger without money moving. Acceptable on staging — but it means **staging cannot be used to validate the billing rail**, and the same fallback would silently mark **production** charges paid if the `PAYMOB_*` keys were ever absent there. A hard failure when `YII_ENV=prod` would be worth considering. **Directly parallel to `BACKLOG_ADMIN.md` SE-03 (S1), which is the same fallback on the renewal cron.**
- **Files:** `common/helpers/PaymobSubscriptionHelper.php:60-86, :99-155, :166-201`, `frontend/controllers/NavagooPlansController.php:615-665`, `frontend/controllers/EarningsController.php:882-920, :937`, `console/controllers/SubscriptionBillingController.php:470-477`, `.env.dist`
- **Verify:** configure the `PAYMOB_*` keys on staging, then re-run the three probes above. **Do not verify by driving a charge on the one available shop — it is irreversible.**

---

# UI parity findings (Track U)

From `03_FINDINGS/shop/_ui-parity.md`, summarised in `REPORT_SHOP.md` §7. **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. **No S1.**

**Counts.** The source headline records **29** items (1 S2 · 17 S3 · 11 S4); its enumerated set
carries **32** ids grading to **1 S2 · 20 S3 · 11 S4**. **All 32 enumerated items are listed below
so none is dropped.** See `REPORT_SHOP.md` §13.

**How these were graded.** Both sides in one browser session — mockup at `localhost:6100`, portal at
`stageshops.navagoo.com` — at 1024 / 1280 / 1440 / 1920, EN + AR. 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 and actions are recorded as *richer data*, not findings.
**No baseline screen was absent. Design tokens match on all 14 sampled values, with no token-level
divergence at all** — see `REPORT_SHOP.md` §7.1.

**Coverage limit — carry this wherever these items are quoted.** UI parity is established for the
**51 surfaces compared** and for no others; **100 of the 151 inventoried baseline surfaces were not
opened on both sides** — no claim is made about them in either direction. `REPORT_SHOP.md` §7.4
lists them.

**Verify (applies to every item below unless it says otherwise).** 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. **Files:** the comparison was browser-side, 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, and where it does not, the item says so rather than guessing.

## S2 — Major (1)

**F-SHP-UI-17 · services-packages · Information architecture · The Packages nav slot is a catalogue, not the owned-package hub**
- **Baseline:** `#/shop/packages` is an **owned-package operations hub** — one row per package a customer has bought, with *customer · package · purchased · validity · per-service sessions remaining · visit count*, an expandable redemption-visits drawer, and **no create action** (catalogue packages are authored on Services ▸ Packages).
- **Observed:** the nav slot is labelled **`Service Bundles`** and routes to `/package`, which renders an empty card whose only affordance is `Create Service Bundle` — **a catalogue surface.** No owned-package view, sessions-remaining column or redemption-visits drawer was reachable from anywhere in the portal, although the Services screen reports `Subscription Packages (2)` in the catalogue.
- **Downgrade condition (carry this):** graded S2 **on the strength of the empty state's create-CTA. Staging has zero bundles, so the populated state could not be observed. If a populated `/package` renders customer-owned rows with sessions-remaining, this downgrades to a relabel (S4).**
- **Verify — precondition P-PKG.** **This is the UI face of PKG-09 (S2, Track L) — one piece of work, two tracks.** PKG-06 (no per-line service model) gates the per-service sessions-remaining column.

## S3 — Moderate (20 enumerated · 17 in the source headline)

Five carry notes below the table because they are substantive or coupled to a Track L item.

| Id | Area | Surface | Axis | Divergence |
|---|---|---|---|---|
| F-SHP-UI-01 | misc | home plan banner · settings notifications | IA / responsive | Riyal-glyph markup double-escaped and printed as literal text — 1 occurrence on Home, **8 on Settings ▸ Notifications, where it escapes its chip and overprints the `WhatsApp` button** |
| F-SHP-UI-02 | booking-modals | pay-now sub-step | IA | No order summary above the collect body — `Services`, `Specialist`, `Booking value` all absent; **nothing on screen identifies what the charge is for** |
| F-SHP-UI-03 | booking-modals | new booking | States | Pressing `Pay now` **persists the booking immediately**; the baseline creates it only inside `collectAndCreate()` after a method is chosen |
| F-SHP-UI-04 | booking-modals | slot picker (create only) | IA | No `Book now` chip and no `EARLIEST` badge — **the same picker renders both correctly inside Reschedule** |
| F-SHP-UI-05 | booking-modals | slot picker | States | The `today` caption renders on a stepped-to future date |
| F-SHP-UI-06 | booking-modals | service picker | Component vocabulary | A sub-screen inside the same panel rather than its own modal, so two headings stack and the panel header bar stays empty |
| F-SHP-UI-07 | booking-modals | all iframe-hosted modals | Component vocabulary / responsive | Empty `h3` title bar (~60px) above the iframe; fixed `h-[70vh]` / `h-[82vh]` leaves ~400px dead space at 1920 |
| F-SHP-UI-12 | booking-detail | detail modal | IA | No labelled `STATUS` / `COLLECTION` / `SETTLEMENT` chip strip; status is only inferable from the timeline |
| F-SHP-UI-13 | booking-detail | timeline | States | Future and reached nodes render the label alone — no `Expected 3:00 PM` / `Awaiting completion` / `Service underway` sub-lines |
| F-SHP-UI-14 | booking-detail | detail modal | Responsive integrity | 768px at 1440 and stacked; three columns only at 1920. **The same body renders three-column in the inline drawer at 1440 — the constraint is the modal shell** |
| F-SHP-UI-16 | booking-detail | services booked | IA | A line amount does not sum to the `TOTAL` — **low confidence, may be data** |
| F-SHP-UI-21 | team | specialists | Component vocabulary / IA | Card grid against a table; **wage type, fixed salary and commission % are not separable** — one unlabelled money chip |
| F-SHP-UI-22 | team | specialist editor | Component vocabulary | A full page at `/agents/update` rather than a modal; **the modal container, and therefore its reuse inside the setup wizard, does not exist** |
| F-SHP-UI-23 | team | specialist editor | IA | No bilingual `Title` field and no `Calendar colour` palette. **Day-calendar columns are still tinted per specialist — the tint is simply not editable** |
| F-SHP-UI-24 | team | specialist editor | States | The `Password` field shows a red `0%` bar and `Too Short` **on an untouched edit form**, on a field that is optional when editing |
| F-SHP-UI-25 | finance | earnings drawer | Responsive integrity | The drawer starts at viewport `y=0`, so its header renders **under the fixed top bar** and the `What's new` / `How to` controls sit on the drawer surface |
| F-SHP-UI-26 | misc | promotion editor | IA | Live customer preview, automatic-vs-code trigger, bilingual label, max-discount cap, categories scope, four of five customer types and the weekday/hour window are all absent |
| F-SHP-UI-27 | rtl | day gutter · shell clock · date chip | RTL / Arabic | **The one real RTL defect** — Latin digits with an untranslated English meridiem, bidi-reordered so the meridiem leads; numeral systems mixed within one page |
| F-SHP-UI-28 | misc | customers booking-link card | IA | No QR element of any kind. (**The mockup's QR is decorative and encodes nothing**, but it is an inventoried panel occupying the card's primary visual slot) |
| F-SHP-UI-29 | misc | invitations | Component vocabulary / IA | A two-tab surface with an inline composer; the batch list is demoted to the second tab and the create flow is a page panel rather than a modal |

**F-SHP-UI-01 — schedule first among the UI items.** It is the only one that makes a control
partly illegible rather than merely divergent, it reproduces in Arabic, and it is a
single-string escaping fix. **Run `npm run build:css` after any Tailwind class change** — the
bundle is a real minified build.

**F-SHP-UI-03 + F-SHP-UI-04 + F-SHP-UI-02 are the same modal and the same flow as CF-CC-05 (S1).**
Schedule them together: F-SHP-UI-03 is the create-on-`Pay now` behaviour whose collect step CF-CC-05
then discards, and F-SHP-UI-04's missing `Book now` is the presentation half of BE-F08 / BE-F15.
A run that opened the sub-step and dismissed it with Escape left `NB-QC-260810015` on the calendar in
*Scheduled / Collect due* — **created, uncollected, with no confirmation step passed.**

**F-SHP-UI-16 is reported at low confidence and must not be ticketed as a rendering bug.** On
`NB-QC-260810015` a single line `Makeup / 30m / 0.00` sits above `TOTAL / 00:30 / 1.00` while the
picker priced Makeup at 1.00; **a second booking reconciles correctly.** Flagged for engineering
confirmation. It may be a data artefact of the 1.00 test service.

**F-SHP-UI-22 has a scope consequence beyond the Team screen.** The baseline reuses the specialist
**modal** inside the first-run setup wizard. The portal has no modal container for it, so the wizard
step cannot reuse this editor as the baseline does — **relevant to any later setup-wizard port.**

**F-SHP-UI-27 is the same time-formatting decision as SN-02 and SN-06 (Track L).** Four date/time
formats coexist for one booking across the list, the detail body, `Booked on` and Finance ▸
Settlement (F-SHP-UI-15), and the Arabic meridiem is untranslated. **One decision closes all four.**

## S4 — Minor (11)

| Id | Area | Surface | Axis | Divergence |
|---|---|---|---|---|
| F-SHP-UI-08 | booking-modals | collect body | IA / label | `Tip (optional)` where the baseline names the specialist and adds the tip-settlement note |
| F-SHP-UI-09 | booking-modals | new booking · actions | Label / copy | `Pay now` and `Collect & complete` carry no amount before a method is chosen; **the pattern exists — the amount appears on `Collect payment · 1.00` once a type is selected** |
| F-SHP-UI-10 | booking-modals | new booking · cancel | Component vocabulary | No required-field accent bars in the New-booking modal (**they are present in Reschedule and Cancel**); `Cancelled by` renders as two full-width buttons at ~5× the baseline footprint |
| F-SHP-UI-11 | bookings-calendar | month view | IA (ordering) | The status legend renders below the month title row rather than above it |
| F-SHP-UI-15 | booking-detail | detail body | Label / copy | Four date/time formats for one booking — `2026-08-06 · 16:30–17:00`, `6 Aug 2026 / 4:30 PM`, `Aug 6, 2026, 4:19:01 PM`, `06/07/2026`; services `TOTAL` reads `00:45` where the row above reads `45m` |
| F-SHP-UI-18 | services-packages | catalogue modal | IA (ordering) / label | `Variants` moved below `Service details`; **`Service name` renders العربية-left / ENGLISH-right while `Service description` renders the reverse, and the Arabic label differs between them** |
| F-SHP-UI-19 | services-packages | catalogue modal · card grid | Label / copy | Stray leading separator in the missing-required hint; `Facials & Skincare` listed **twice** in the Category select; category badges truncate mid-word in both EN and AR |
| F-SHP-UI-20 | services-packages | services screen | States | Repeated `404`s for `/uploads/packages/royal_spa.jpg` and `golden_hair.jpg` on load |
| F-SHP-UI-30 | misc | sidebar | Responsive integrity | At 1440×900 the scrolling nav list clips the active item when it is near the bottom (`Branches` cut in half, not scrolled into view on load) |
| F-SHP-UI-31 | misc | settings | States | `ReferenceError: jQuery is not defined` at `/settings/index:1145` — **flagged because inline handlers on that page may depend on it** |
| F-SHP-UI-32 | misc | customers | Label / copy | The Customers subtitle names the shop `مركز الجمال للخدمات النسائية` where every other screen in the same session reads `بيوتي سنتر / Beauty Center` |

**F-SHP-UI-18 and F-SHP-UI-19 are copy and label items and must land in both `ar/` and `en/`
message files** per the repo-wide bilingual requirement. F-SHP-UI-18 carries **three
inconsistencies inside one modal** — the EN/AR column order differs between two fields on the same
form, and the Arabic label for "Arabic" differs between them.

**F-SHP-UI-31 was not investigated further.** Console errors were present on several portal pages
(1–2 per load) during the session; two were identified and filed (this and F-SHP-UI-20) but **the
remainder were not enumerated, so these two are not a complete list.**

---

# Probes outstanding

**35 probes across 31 distinct contracts** are recommended and were not run: 6 each in
`booking-engine`, `shop-finance` and `collect-complete`, 5 in `classification`, 4 each in
`group-bookings`, `packages` and `settings-notifications`. They are **not** reproduced here — each
sits at the foot of its area file with a written recipe and a stated confirm/refute condition, and
the relevant one is already summarised in each item's **Verify** line above.

**Two are blocked rather than merely unrun:**

- **C-SHP-007** — whether freebie minutes are folded into `ShopService.service_period` at save time
  could not be settled from the files in scope. If they are stored separately and never added,
  **every occupied block for a service carrying a freebie is short by those minutes** and
  `bookingDuration()`'s sum omits what the baseline's `totalDuration()` includes. **Unresolved, not
  cleared.**
- **C-SHP-073** — the CF-CC-05 pay-now network probe needed a shared browser or curl session that
  was not available. The finding rests on code read plus F-SHP-UI-03.

**Withdrawal conditions are part of the findings.** Several probes state the condition that would
narrow or withdraw their finding — C-SHP-024's most explicitly ("`amount_collected = 0` and no
processing row would mean the fallback is not reachable in practice and the finding drops to the
api-created case only"). **Do not drop those conditions when a ticket is written.**

---

# Sequencing notes

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

| Do together | Why |
|---|---|
| **F-FIN-01 → F-FIN-10** (in this order, or one release) | **Non-negotiable.** F-FIN-10 alone converts a display error into a 19.67-per-walk-in payout error |
| F-FIN-01 + CF-CC-04 + CF-CC-08 + BE-F06 + BE-F13 | One `WalkInBookingService::create` assignment block (S1 Group A) |
| PKG-01+02 + PKG-03 + PKG-05 + CF-CC-02 (+ PKG-04, PKG-07, PKG-08) | One package-redemption guard, four S1s (S1 Group C) |
| F-FIN-02+03 + F-FIN-16 + F-FIN-08 + GRP-02 + CLS-02 | One decision on which finance rail is authoritative, then the completion chokepoint (S1 Group B, §9.1 and §9.3) |
| BE-F04 + CF-CC-01 + BE-F11 | One endpoint — the same behaviour under three ids |
| BE-F02 → BE-F01 | Settle the inverted `canPerform()` before reading BE-F01's can-perform leg |
| SN-02 + SN-06 + F-SHP-UI-15 + F-SHP-UI-27 | One time-formatting decision, four surfaces (two tracks) |
| SN-04 + CF-CC-06 | One entitlement resolver for payment methods |
| SN-05 + SN-09 | One `channelUsable()` correction; also closes `BACKLOG_ADMIN.md` NOTIF-01 |
| PKG-06 → PKG-09 → F-SHP-UI-17 | The per-line model gates the sessions-remaining column, which gates the owned-package hub |
| CF-CC-05 + F-SHP-UI-03 + F-SHP-UI-02 + F-SHP-UI-04 | One modal, one flow, two tracks |
| F-FIN-07 + BE-F07 | Both are the cancel/no-show branch failing to produce the earnings state settlement expects |
| F-SHP-UI-18 + F-SHP-UI-19 + F-SHP-UI-32 | Copy changes — each needs both `ar/` and `en/` message files |

**Product decisions embedded in this backlog, not engineering tasks:** **SN-03** (the deliberate
permissive entitlement posture — documented in code as a live-rollout choice), **PKG-05's** package
cancellation policy (the baseline ships an admin toggle, default off; the portal has neither the
toggle nor the block, so the policy has to be chosen before the guard is written), **CLS-05** (an
identifier rename that touches stored rows), and **H6's** production posture (whether an absent
`PAYMOB_*` key should hard-fail rather than simulate PAID).

**Two dependencies outside this portal.** **H3** needs schema plus an API-contract decision with the
mobile owner. **CLS-01** needs the api signup flow to persist the invite token at account creation —
its own service docblock records this. Neither is closable inside `frontend/` alone.

**Before verifying anything financial, read the Preconditions table.** Commercial rates read zero
across staging for most of this audit; a probe raised them mid-way and left them raised, **but rows
written earlier carry their stamped zero rate, so a probe must create fresh records.** A fix
verified against zero rates looks like a no-op in both directions.
