# Shop portal — parity audit report

**Portal:** shop (`frontend/` — https://stageshops.navagoo.com)
**Baseline:** Navagoo 2.0 mockup, branch `dev`, commit `cb48c8d` (v0.28.0)
**Date:** 2026-08-06
**Status:** Findings, evidence and the ticket-ready backlog are in `03_FINDINGS/shop/`,
`02_EVIDENCE/shop/` and `BACKLOG_SHOP.md`.

This is a technical comparison against a pinned baseline, written to the register in
`00_SPEC/PARITY_AUDIT_SPEC.md` §11: observed state versus baseline, never attribution. It is the
second phase of the same audit as `REPORT_ADMIN.md` and shares its structure, its severity rubric
and its evidence conventions.

> ## ⚠ Remediation ordering — F-FIN-01 and F-FIN-10 must ship together
>
> F-FIN-01's spurious walk-in processing fee currently reaches only displayed figures and the
> invoice rail. It never reaches payout **precisely 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 §6.

---

## 1. What was compared

| Side | Ref | Verified |
|---|---|---|
| Mockup | `Navagoo 2.0`, branch `dev`, `cb48c8d` — v0.28.0 "aura login + shop first-run setup wizard" | 2026-08-06 · `git fetch` succeeded · 0 ahead / 0 behind `origin/dev` · working tree clean |
| Portal (local) | `NavagooBackend`, branch `parity-audit` off `tailwind-poc`, `5efbafc9` | 2026-08-05, pulled from `origin/tailwind-poc` |
| Portal (staging) | https://stageshops.navagoo.com — shop `بيوتي سنتر` / `Beauty Center` (`NSH-251210001`), plan Growth · free trial | fingerprinted below |

The mockup baseline is the shop-side identity **Ghada Al-Balawi — Owner · Beauty Center** at
`http://localhost:6100/` (not 5173 — the mockup pins `strictPort: true` on 6100). Vite serves the
live working tree, so the server represents `cb48c8d` only while that tree is clean; it was
verified clean on the capture date.

### Staging build fingerprint

Neither deployment exposes a version string, so the build is fingerprinted by served-asset
metadata (`02_EVIDENCE/environment.md`):

| Asset | Last-Modified | Bytes | ETag |
|---|---|---|---|
| `stageshops.navagoo.com/css/tailwind.css` | 2026-08-03 17:41:58 GMT | 104,719 | `1990f-658280e0f37ce` |
| `stageshops.navagoo.com/css/custom.css` | 2026-05-12 21:02:28 GMT | 6,896 | `1af0-651a52e461178` |

### Divergence assessment (staging vs local HEAD)

The shop Tailwind bundle was built on staging 2026-08-03 17:41 GMT; local `HEAD` is dated
2026-08-05. Six commits sit between them. Five are `api/` tier work — the mobile surface,
explicitly out of scope per spec §2. The sixth (`e5741a6b`, an auth toast fix in `frontend/`) is
dated the same day as the staging build and may well be deployed. The last git change to
`frontend/web/css/tailwind.css` was `8f495d89` (2026-08-02), consistent with a rebuild on 08-03
carrying that commit. **Staging is therefore a sound proxy for local `HEAD` for this portal.**

Three limitations travel with that assessment and are not resolved:

1. Asset timestamps bound the build; they do not prove it. PHP on staging could be newer or older
   than the CSS bundle. Findings are reported against observed staging behaviour, with local code
   read as corroboration — never the reverse.
2. `custom.css` on the shop host dates to 2026-05-12. The aurora work is delivered through the
   Tailwind bundle path, so this is not evidence of a stale deployment — but the build cannot be
   dated from that file.
3. There is no version endpoint on either deployment.

### Coverage

- **82 of 82 contracts evaluated**, every one carrying a settled verdict
  (`01_BASELINE/shop/contracts.md`, `C-SHP-001` … `C-SHP-082`). No contract was recorded
  `needs-probe` or `unverifiable`.
- **151 baseline surfaces inventoried** — 20 screens, 24 tabs, 45 modals, 6 drawers, 56 panels
  across eleven areas (`01_BASELINE/shop/inventory.md`), read from mockup source before any
  implementation was observed.
- **51 surfaces UI-compared on both sides** — 14 screens, 12 tabs, 9 modals, 2 drawers, 14 panels
  (`03_FINDINGS/shop/_ui-parity.md` §10), at 1024 / 1280 / 1440 / 1920 and in EN + AR.
- **Contract pass rate: 30.5%** (25 pass / 21 fail / 36 partial across 82), scored on Track L
  only. §4 has the per-area table and the reading.
- **Four seeded hypotheses verified or re-tested** (H3, H4, H6, plus re-tests of H5 and H7) against
  the live portal, with screenshots — §8.
- **35 live probes are recommended and not run**, across 31 distinct contracts. Each carries a
  written recipe and a stated confirm/refute condition in its area file. See §12.
- Findings were produced by a contract verification run followed by an adversarial refutation
  pass; every S1 was then independently re-verified by a hostile pass requiring quoted code,
  production reachability, a mockup baseline check and a worked example with numbers. Outcome:
  **11 upheld, 0 refuted outright, 2 downgraded, 3 merges.**

**What was not reached.** UI parity covers 51 of the 151 inventoried surfaces. Group bookings
end-to-end, calendar interaction states, booking-detail edge states, the Finance `Detailed charges`
and `Invoices` tabs, Team Payroll and Structure, the Services extras tabs, Customers
Classifications and Freeze-list, the Plans confirm/add-card modals, most Home dashboard panels, the
whole shell layer (account settings, notifications bell, terms gate, setup wizard, collapsed
sidebar) and Help content were **not compared — no claim is made about them in either direction.**
§7.4 and §12.1 state the boundary in full.

---

## 2. Verdict

**The shop portal is a substantially complete and disciplined port of the mockup's shop surface —
and the money rails it runs on diverge at eleven points, nine of which produce an incorrect amount
or destroy paid customer value on an ordinary operator action.** Both halves of that sentence are
load-bearing and neither cancels the other.

**On the completeness.** All 82 pre-registered contracts had an implementing code path to evaluate
against. No baseline screen was found absent. Screen-level information architecture, tab sets,
filter toolbars, status vocabulary and the whole Analytics / Plans / Finance widget inventory match
the baseline. **Design tokens are identical — 14 of 14 sampled values byte-for-byte, including the
full status-badge ramp** (§7.1). RTL mirrors correctly on every sampled surface. Of the 29 UI
findings, **28 are S3 or S4 and the one S2 is a nav-slot divergence** — the UI dimension is
materially stronger than the behavioural one, exactly as it was on the admin side. The contract
pass rate, **30.5%**, is ten points above the admin portal's 20.5%. (§13 records a counting
discrepancy inside the UI source between its headline total and its enumerated set; it does not move
the severity distribution, which is one S2 and the rest S3/S4 on either count.)

**On the divergence.** The behavioural comparison returned **11 S1 and 27 S2**. That is more S1s
than the admin phase found (7) against fewer contracts, and they are not spread thin: **nine of the
eleven sit in the completion, collection and package-redemption rails**, and each has a worked
example with numbers behind it. The concrete shapes are a walk-in charged a **19.67** processing
fee on cash that never touched the payment rail (F-FIN-01 / CF-CC-04); a payout that overpays by
the same **19.67** because it never subtracts that fee (F-FIN-10); a redeemed package session
carrying **16.96** of fees against a baseline **0.00** (PKG-01+02); **800** of withdrawable
settlement credit minted for four redemption visits on which 0.00 was collected, against a 760 sale
(PKG-03); a customer permanently losing **190.00** of pre-paid entitlement when a shop cancels the
visit (PKG-05); and cash taken at the counter that the create-then-pay-now step discards without
telling the operator (CF-CC-05). The remaining two admit a booking the baseline rejects: creation
never runs the placement check (BE-F01), and an overnight business day pairs a post-midnight minute
with the wrong calendar date, leaving the slot re-bookable (BE-F03).

**The divergence has a shape, and the shape is more useful than the count.** Two patterns account
for most of it. First, **two finance rails coexist and disagree** — the legacy
`Earnings` / `shop_earning` path and the newer `charge` ledger. On one navagoo-sourced booking the
same Earnings screen shows Navagoo fees of **19.67** in its tile and **23.00** in the row beneath
(F-FIN-02+03). Second, **package redemption has no guard on any portal path**:
`Booking::getIsPackageRedemption()` exists and has zero call sites repo-wide, and the
`reinstateSession()` / `reversePackageRedemptionCharge()` helpers exist and are called only from
the `api/` tier. That single absence produces four of the eleven S1s. §9 develops both.

**Three qualifications this verdict must carry.** First, **the two most expensive-looking findings
are coupled and must not be fixed independently** — see §6; fixing the payout omission alone starts
charging shops 19.67 per walk-in that they currently only see on a screen. Second, **commercial
rates read zero across staging for most of this audit**; a probe raised them mid-way and left them
raised, but fee arithmetic cannot be validated on staging without configuring rates first, and a
naive probe of any fee finding will return 0.00 in either direction. Third, **UI parity is
established for the 51 surfaces compared and for no others** — 100 of the 151 inventoried surfaces
were not opened on both sides.

**One thing changed for the better during the audit and should be read as the positive it is.**
When the admin phase ran, `EntitlementService::shopCanAccess()` had no callers and no tier gate was
reachable from the portal. `frontend/components/EntitlementFilter.php` has since shipped, attached
at application level, and **tier gating was verified working live**: a Growth shop navigating to
`/branch` receives the plan-gate interstitial instead of the Branches screen. §8.

---

## 3. Scorecard

Per area: confirmed findings by severity, and contracts evaluated. Sorted worst-first by severity
weight (S1×100 + S2×10 + S3×1).

| Area | S1 | S2 | S3 | S4 | Confirmed | Refuted | Contracts evaluated | Probes needed |
|---|---|---|---|---|---|---|---|---|
| [shop-finance](03_FINDINGS/shop/shop-finance.md) | **3** | 6 | 3 | 1 | 13 | 2 | 21 | 6 |
| [packages](03_FINDINGS/shop/packages.md) | **3** | 5 | 0 | 0 | 8 | 0 | 9 | 4 |
| [collect-complete](03_FINDINGS/shop/collect-complete.md) | **3** | 4 | 1 | 0 | 8 | 0 | 6 | 6 |
| [booking-engine](03_FINDINGS/shop/booking-engine.md) | **2** | 5 | 6 | 1 | 14 | 1 | 22 | 6 |
| [settings-notifications](03_FINDINGS/shop/settings-notifications.md) | 0 | 3 | 4 | 1 | 8 | 1 | 8 | 4 |
| [classification](03_FINDINGS/shop/classification.md) | 0 | 2 | 2 | 1 | 5 | 0 | 5 | 5 |
| [group-bookings](03_FINDINGS/shop/group-bookings.md) | 0 | 2 | 1 | 1 | 4 | 3 | 11 | 4 |
| **Total** | **11** | **27** | **17** | **5** | **60** | **7** | **82** | **35** |

**Worst:** `shop-finance`, `packages` and `collect-complete` — 3 S1 each, nine of the eleven
between them. `packages` carries the highest defect density: 8 confirmed findings against 9
contracts, with no S3 or S4 at all — every divergence found there is S1 or S2.
**Best:** `group-bookings` — no S1, 2 S2, and the area where refutation was most active (3 of the
audit's 7 refuted candidates, including one where the stated baseline was itself shown to be a
misreading of the mockup).

**Findings outside the scorecard.** Three hypothesis outcomes (H3, H4, H6 — §8) and the **UI parity
set** (`03_FINDINGS/shop/_ui-parity.md`, summarised in §7) sit outside this table. The UI set is
Track U, which spec §4.3 requires to be scored separately and never blended into the contract
counts. Every one of them is carried into `BACKLOG_SHOP.md`.

**One duplication the table preserves rather than silently removing.** `CF-CC-01` and `BE-F04` are
the same behaviour (`AgentsBookingsController::actionUpdateStatus` writing `status` without the
FSM), found independently by two area runs and recorded in both files; each area file says so
explicitly. Both are counted above, as recorded. §13 records this rather than resolving it.

---

## 4. Contract pass rates (Track L)

Spec §4.3 scores the two tracks separately, so what follows is the **behavioural** dimension only —
the 82 pre-registered contracts across the seven verification areas. UI parity is scored in §7 and
the two are never averaged. Source: `03_FINDINGS/shop/_PASS_RATES.md`.

| Area | pass | fail | partial | needs-probe | unverifiable | Evaluated | Scored (denominator) | Pass rate |
|---|---|---|---|---|---|---|---|---|
| packages | 1 | 5 | 3 | 0 | 0 | 9 | 9 | **11.1%** |
| shop-finance | 4 | 6 | 11 | 0 | 0 | 21 | 21 | 19.0% |
| booking-engine | 7 | 6 | 9 | 0 | 0 | 22 | 22 | 31.8% |
| collect-complete | 2 | 1 | 3 | 0 | 0 | 6 | 6 | 33.3% |
| settings-notifications | 3 | 1 | 4 | 0 | 0 | 8 | 8 | 37.5% |
| group-bookings | 5 | 1 | 5 | 0 | 0 | 11 | 11 | 45.5% |
| classification | 3 | 1 | 1 | 0 | 0 | 5 | 5 | **60.0%** |
| **Overall** | **25** | **21** | **36** | **0** | **0** | **82** | **82** | **30.5%** |

**Worst:** `packages` at **11.1%** — one contract of nine matches the baseline exactly.
**Best:** `classification` at **60.0%**.

**Denominator rule.** `pass rate = pass ÷ (pass + fail + partial)`. `needs-probe` and
`unverifiable` are excluded from **both** numerator and denominator, so a blocked probe can never
move the score in either direction. In this run both excluded classes are **0**, so the denominator
equals the number of evaluations in every row. The rule is stated so the number stays comparable
against runs where it does bite.

### 4.1 How to read 30.5%

- **The dominant verdict is `partial` — 36 of 82.** Taken with §3, that describes **broad coverage
  with mixed exactness**: nearly every contract has an implementing code path, and roughly three in
  ten match the baseline precisely. `fail` accounts for 21 of 82 — a materially higher share than
  the admin phase's 16 of 83, which is consistent with the finance and package areas.
- **The pass rate and the severity counts do not order the areas the same way, and severity wins.**
  `booking-engine` scores 31.8% — above the overall rate — while carrying 2 S1s; `classification`
  scores 60.0% with no S1 at all. Remediation priority follows §3 and §5, not this table.
- **A high pass rate in an area is not evidence its S1s are minor.** `collect-complete` sits at
  33.3% on six contracts and carries three S1s, one of which loses cash at the counter.
- **The rate is higher than the admin phase's and the S1 count is higher too.** These are two
  instruments measuring different things: the pass rate counts exact baseline matches; severity
  weights consequence. Neither is a summary of the other.

### 4.2 A note on behavioural coverage

The verdicts record `needs-probe = 0` and `unverifiable = 0`, which at face value states that every
contract was settled from the evidence available. The same run separately recommends **35 live
probes** across 31 contracts, each with a confirm/refute condition. Those two outputs are in the
same tension the admin phase recorded (`REPORT_ADMIN.md` §4.2). **The probe list is the more
conservative reading and is the one this report follows: no claim of complete behavioural coverage
is made here.** The `_PASS_RATES.md` source states the distinction directly — the probe tables list
contracts whose static verdict would be *strengthened* by a runtime observation, not contracts
whose verdict is unsettled.

---

## 5. The eleven S1 findings

Each was independently re-verified against four requirements: quoted code from the cited files,
evidence the path is reachable in production, a check against the mockup baseline, and a concrete
failure scenario with numbers. All eleven met all four. **Amounts below are reproduced exactly as
recorded.** They are grouped so shared root causes sit together; the group heading states what the
group has in common.

---

### Group A — the walk-in record is created without a payment split

*Two S1s, one mechanism: `WalkInBookingService::create` assigns neither `payment_mode` nor
`amount_collected`, and `FinanceLedgerService::amountCollected()` then falls through to
`total_amount`.*

#### S1-1 · F-FIN-01 — a pay-on-visit booking reads as fully collected
**Area:** shop-finance · **Contract:** C-SHP-024 · 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`.

**Portal.** `FinanceLedgerService::amountCollected()` (`:1099`) walks
`['amount_collected','final_collected_amount','paid_amount','total_amount']` and returns the first
non-null. The `booking` table carries only `amount_collected` — nullable, no default
(`m260608_120100_add_payment_mode_to_booking.php:22`); the other two live on `earnings`/`payment`,
so `hasAttribute` is false and the chain falls to `total_amount`, which is never null.
`WalkInBookingService` never assigns `amount_collected`; `actionCollect` writes
`in_store_collected` instead. The same helper feeds `customerRefund`, the uncollected-no-show
guard, `bookingNavagooPayout` and `bookingSettlement`.

**Failure scenario.** Walk-in of **460.00** taken in cash, processing 3.5% + 1.00, VAT 15% — a
charge of **17.10** plus **2.57** VAT = **19.67 of fee that must not exist.**

**Internal contradiction worth carrying.** `outstandingBalance()` reads `amount_collected` directly
and therefore resolves to 0 for the same booking. **The same booking is unpaid for the collect UI
and fully collected for the fee engine.**

**Rail.** Displayed figures and the invoice rail — **not** payout. That is not a mitigation; it is
the reason §6 exists.

**Files:** `common/components/FinanceLedgerService.php`,
`common/migrations/db/m260608_120100_add_payment_mode_to_booking.php`,
`frontend/controllers/BookingController.php`

---

#### S1-2 · CF-CC-04 — the collect path stamps a processing fee on counter cash
**Area:** collect-complete · **Contract:** C-SHP-071 (folded symptom: C-SHP-074) · deviation ·
built-not-working

**Baseline.** In-store money raises **no** charge rows and never produces a processing fee; the
processing-fee basis is the online amount only.

**Portal.** The collect path calls `BookingCompletionService::ensureEarnings`, which always calls
`FinanceLedgerService::ensureProcessingFee`. `buildProcessingFee` resolves its basis through the
same fallback chain as S1-1. Completing or collecting on a shop-portal walk-in therefore stamps a
`payment_processing_fee` row of `round2(total_amount × rate% + fixed)` plus VAT against cash taken
at the counter, **and that fee is non-refundable by design**. `api`-created bookings set
`amount_collected` explicitly (including 0), so the defect is specific to bookings created in the
shop portal.

**Failure scenario.** Portal walk-in **460** cash — a `payment_processing_fee` row,
`base_amount 460.00`, 3.5% + 1.00 = **17.10 + 2.57 VAT = 19.67 netted from settlement**,
non-refundable, on money that never touched the rail.

**Folded symptom — CF-CC-08 (S2), kept visible so the fix is not written twice.** The
online / deposit / on-visit split is implemented only on the party path
(`GroupBookingService::collectAmount`). On the solo path `WalkInBookingService::create` references
neither `payment_timing` nor `payment_mode` nor `amount_collected`, so a walk-in stores the DB
default `payment_mode='online'` with a NULL `amount_collected` regardless of the timing the manager
picked, 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"*). With
`depositPct = 30` and a **460** total the baseline collects **138.00** now and leaves **322.00** due
on visit; the portal records nothing as collected online and presents **460.00** as due in store.
**Downgraded from S1 on re-verification: 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/components/FinanceLedgerService.php`,
`common/services/BookingCompletionService.php`, `common/services/WalkInBookingService.php`,
`frontend/controllers/BookingController.php`

---

### Group B — the marketing fee and the payout run on the legacy rail

*Two S1s in the same dual-rail structure: the `charge` ledger and the legacy
`Earnings` / `shop_earning` engine both exist, and the shop's numbers come from the older one.*

#### S1-3 · F-FIN-02+03 — one dual-rail defect, two code fixes
**Area:** shop-finance · **Contract:** C-SHP-027 · deviation · built-not-working

*Recorded separately as F-FIN-02 and F-FIN-03; re-verification established they are one defect
under one contract. Both ids are retained. **Fixing either side alone leaves the shop on a wrong
number.***

**Baseline.** A marketing-fee row is created **if and only if** the booking's customer
classification is `navagoo_sourced`; it is pending while the booking is provisional and 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.

**Portal — side one, the ledger rail goes empty (`gap · not-built`).**
`deriveBookingCharges` — the only caller of `buildMarketingFee` — is reached from four places only:
`GroupBookingService` (group creation and group cancels), `frontend BookingController::actionCancel`,
the backend classification override, and `DemoController`. The normal completion chokepoint,
`BookingCompletionService::ensureEarnings`, calls `ensureProcessingFee()` and nothing else. A
normally-completed or no-show solo booking therefore never receives a `marketing_fee` ledger row.

**Portal — side two, the legacy rail charges ungated (`deviation · built-not-working`).**
`Earnings::calculateNavagooMarketingFees` (`Earnings.php:591-596`, **which takes no classification
argument at all**) 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**, and
stamps it onto `earnings.navagoo_marketing_fees` and thence
`shop_earning.navagoo_marketing_fees`. That column drives the Earnings table's fee column and
`net_collectible_amount`, which is what the withdrawal actually nets.

**Failure scenarios, both rails.**

- **Displayed side** — navagoo-sourced **460** online: the tile shows Navagoo fees **19.67** against
  a baseline **42.67**, and Net earnings **440.33** against **417.33**, while the row beneath shows
  **23.00** taken from the legacy column. **Same screen, two answers for the same booking.**
- **Payout side** — `shop_owned` walk-in **460** with `platform_commission` 5% gives
  `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`

---

#### S1-4 · F-FIN-10 — the payout never subtracts processing fees
**Area:** shop-finance · **Contract:** C-SHP-041 · deviation · built-not-working

**Baseline.**
`netPayout = Σ booking collected + tips − marketing − processing − notif − other fees − fee VAT +
Σ package net payouts`. Mockup `store.ts:1207-1250`, with `otherFeeRows` explicitly `!c.bookingId`.

**Portal.** `WithdrawalBundlingService::buildAndPersist` (`:261`) accumulates
`shop_earning.net_collectible_amount`, then subtracts only `total_notif_fees`.
`ShopEarning::calculateNetCollectibleAmount` = `final_collected + tip − navagoo_marketing_fees −
vat_navagoo`, so **the payment-processing fee is never subtracted from the payout.**
`TYPE_PROCESSING_FEE` appears nowhere in the file. 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 not netted.

**Failure scenario.** One eligible online booking of **460**, processing **17.10 + 2.57** VAT,
marketing **20.00 + 3.00** — the portal pays **437.00** where the baseline pays **417.33**,
**19.67 overpaid**. An unpaid invitation SMS fee of **0.29** carrying no `booking_id` takes it to
**19.96**.

**Files:** `common/components/WithdrawalBundlingService.php`, `common/models/base/ShopEarning.php`

**See §6 before scheduling this.**

---

### Group C — package redemption has no guard on any portal path

*Four S1s from one absent concept. `Booking::getIsPackageRedemption()`
(`common/models/Booking.php:57-60`) exists and has **zero call sites repo-wide**;
`PackageEntitlement::reinstateSession()` (`:206`) and
`FinanceLedgerService::reversePackageRedemptionCharge()` (`:1503`) exist and are called **only from
`api/controllers/BookingController.php:1185,1191`**.*

#### S1-5 · PKG-01+02 — a redeemed session is charged marketing fees twice, then a third time on cancel
**Area:** packages · **Contract:** C-SHP-064 · deviation · built-not-working

*Recorded separately as PKG-01 and PKG-02; re-verification established 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`, with a comment
stating the sourcing fee was taken at the sale. The package-link guard is **first** in the charge
derivation, so no booking-lifecycle transition can raise fee rows on a redemption visit.

**Portal — site one.** `buildPackageRedemptionCharges()` (`FinanceLedgerService.php:1429-1467`) has
no zero-charge guard and stamps a marketing-fee `Charge` at redemption time for `navagoo_sourced`
customers (basis = `noVat(entitlement.per_session_price)`, rate = shop marketing rate, floored at
`minMarketingFee`, VAT on top, status UNPAID).

**Portal — 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 shop-portal cancel (`BookingController.php:780`) computes a full
booking-style marketing fee on the redemption's `total_amount` and **appends** it to the
redemption-time row.

**Failure scenario.** Package **760** over **4** sessions, marketing 5%, VAT 15%. The 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`

---

#### S1-6 · PKG-03 — a completed redemption mints withdrawable credit for money never collected
**Area:** packages · **Contract:** C-SHP-065 · deviation · built-not-working

**Baseline.** `bookingRevenue = 0` for a booking with a package link — the package's value was
recognised at the sale, so counting the visit again double-counts. Mockup `finance.ts:508` returns
0 on `customerPackageId`.

**Portal.** `FinanceLedgerService::bookingRevenue()` (`:654-660`) has no package guard: a completed
redemption returns `round2(bookingValue)`, and the shop dashboard's revenue window
(`SiteController::shopRevenueForWindow`) sums it. Completing the visit from the shop portal also
runs `BookingCompletionService::ensureEarnings()`, which mints a Payment + Earnings + ShopEarning
for a booking on which nothing was collected: `BookingHelper.php:612` sets
`earnings.amount = booking.total_amount`, `Earnings::calculateFinalCollectedAmount()` makes
`final_collected_amount = 200`, and `ShopEarning::calculateNetCollectibleAmount()` turns that into
**withdrawable cash**.

**Failure scenario.** Four completed redemptions at a catalogue price of **200** give dashboard
revenue **+800** (baseline 0) **and 800 of withdrawable settlement credit for visits where 0.00 was
collected**, against a **760** sale.

**Severity note recorded during re-verification.** 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`

---

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

**Baseline.** A package-redemption visit is pre-paid and already consumed, so a cancellation
transition 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 this is designed behaviour rather than an unconsumed
flag.

**Portal.** `actionCancel()` (`:708-811`) was read in full: no `package_entitlement_id` branch, no
`reinstateSession()`, no `reversePackageRedemptionCharge()`. There is no model hook acting as a
safety net, and the second portal cancel path (`AgentsBookingsController.php:331`) is equally bare.

**Failure scenario.** 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.** 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`

---

#### S1-8 · CF-CC-02 — a pre-paid redemption cannot be completed without recording money never taken
**Area:** collect-complete · **Contract:** C-SHP-069 · gap · not-built

**Baseline.** `outstandingBalance` is forced to **0** for a package-redemption visit, so such a
visit completes without any collection step. Mockup `finance.ts:520` opens with
`if (b.customerPackageId) return 0`, and its comment names this exact failure.

**Portal.** `FinanceLedgerService::outstandingBalance` (`:1087-1094`) has no package-redemption
branch, and `Booking::getIsPackageRedemption()` (`common/models/Booking.php:57-60`) has **zero call
sites**. A redemption booking is persisted with `total_amount` = the service's real value and
`amount_collected = 0`, so outstanding resolves to the full service value.

**Failure scenario.** A **460 SAR** pre-paid package session — **Complete** is refused with
`requireCollect`, outstanding shows **460**, the collection badge reads *pending* permanently. The
only escape is **Collect & complete**, which writes `in_store_collected = 460.00`,
`in_store_method = 'cash'` **for money that was never taken.**

**Files:** `common/components/FinanceLedgerService.php`, `frontend/controllers/BookingController.php`,
`common/models/Booking.php`

---

### Group D — the create-then-pay-now step fails silently

#### S1-9 · CF-CC-05 — the pay-now collection and card tip are discarded without an error
**Area:** collect-complete · **Contract:** C-SHP-073 · 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.

**Portal.** The new-walk-in modal's Pay-now sub-step posts to the same `/booking/collect` endpoint
against the booking it just created (`booking-new.js:892-915`). That booking is `STATUS_SCHEDULED`
(`WalkInBookingService.php:148`), and `BookingTransitionService::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'`.

**Failure scenario.** Create a walk-in for **460**, press Pay now, choose cash — **HTTP 200 with
`success:false`**, the modal closes reporting *saved*, `in_store_collected` stays NULL, status stays
SCHEDULED, and the card tip is discarded. **Cash is in the till and unrecorded.**

**The portal fails on a step the mockup never takes** — the baseline's pay-now path does not attempt
a completion at all.

**Files:** `frontend/web/js/booking-new.js`, `frontend/controllers/BookingController.php`,
`common/services/WalkInBookingService.php`, `common/components/BookingTransitionService.php`

---

### Group E — the booking-engine writers

*Two S1s on the creation and move paths. Both admit a booking the baseline rejects; both sit beside
a correct implementation the writer does not reach.*

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

**Baseline.** Booking 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
`checkPlacement` before persisting, **explicitly to catch programmatic misuse.**

**Portal.** `checkPlacement` is called from `BookingController:536`,
`BookingCalendarController:570`, `GroupBookingService:295/:528` — **never from
`WalkInBookingService`**. `WalkInBookingService::checkBlockConflicts` runs only an `agent_slots`
overlap query plus a portal-only "earlier open booking" sequential rule. Time-off lives in the
separate `AgentTimeOff` table, invisible to both, and `workingBlocks` / `canPerform` are never
consulted. The modal pre-filters using server-generated slot chips, **but that list is a snapshot.**
The same rejection set is enforced correctly on the reschedule and reassign paths — creation is the
only writer that bypasses it.

**Failure scenario.** 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`

---

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

> **Attribution correction — read this before opening any file.** The originally named function,
> `fromBusinessMinutes()` (`BookingScheduleService:192-195`), does collapse the value mod-1440,
> **but it has zero callers — it is dead code.** An engineer led to it will patch it and see no
> change in behaviour. The finding leads with the live sites below, which perform the same collapse
> inline.

**Baseline.** The inverse of `toBusinessMinutes` must roll the calendar date forward for values
above 1440 and return a local wall-clock timestamp, so a next-morning slot on an overnight business
day round-trips. `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'`.

**Portal (live sites).** `freeSlots:541` emits the business-day date concatenated 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`) likewise persists `booking_date` as the business-day date with
`minToClock($startMin)`. Neither site carries a date roll. The portal's own `businessDayOf()` then
re-attributes the resulting row to the **previous** business day, so `dayLayout` drops it from the
intended day's grid and `checkPlacement`'s overlap scan skips it.

**Failure scenario.** Shop open 18:00, close 02:00; business day **2026-08-05**; business minute
**1500** gives a slot value of **`2026-08-05 01:00`** where it should be **`2026-08-06 01:00`**. The
booking persists `booking_date='2026-08-05'`, `from_hour='01:00'`, and
`businessDayOf('2026-08-05', 60)` resolves to **`2026-08-04`** — the row lands on the previous
night's session, is dropped from that day's grid, is skipped by `checkPlacement`'s filter, and
**the 01:00 slot is offered again, producing a double booking.**

**Precondition.** `close_at <= open_at` — a supported configuration.

**Files:** `frontend/components/BookingScheduleService.php`,
`frontend/controllers/BookingController.php`

---

## 6. The remediation-ordering constraint

**F-FIN-01 and F-FIN-10 must ship together. This is the single most consequential scheduling fact
in this report.**

The two findings are currently in a state that masks each other's cost:

| | F-FIN-01 (spurious walk-in processing fee) | F-FIN-10 (payout omits processing fees) |
|---|---|---|
| What it does today | Stamps a **19.67** processing fee on a walk-in where nothing was collected on the rail | Never subtracts any processing fee from `net_transferable_amount` |
| Where it lands today | Displayed figures and the invoice rail | The actual payout |
| Net effect on cash | **None** — the spurious fee is never deducted from what the shop is paid | **19.67 overpaid** per eligible online booking (**19.96** with a 0.29 non-booking invitation fee) |

**The spurious fee reaches only screens and invoices *because* the payout rail does not read
processing fees at all.** Correct the payout rail on its own and the fee stops being a display error
and starts being a deduction: **every walk-in begins costing the shop 19.67 it does not owe.**

**The ordering that is safe:** fix F-FIN-01 — assign `payment_mode` / `amount_collected` on the
walk-in create path so the processing-fee basis is the online amount, and suppress the row when that
is zero — **before or in the same release as** F-FIN-10. Fixing F-FIN-01 first is harmless; it only
corrects displayed and invoiced figures. Fixing F-FIN-10 first is not.

**Two consequences for verification.** First, a fix to F-FIN-10 verified in isolation will look
correct — the payout arithmetic will match the baseline formula — while producing a new overcharge
on exactly the booking type the shop portal creates most. Second, **CF-CC-04 is the same mechanism
as F-FIN-01 seen from the collect path**; the walk-in create-path assignment closes both. See
`BACKLOG_SHOP.md` Group A.

---

## 7. UI parity (Track U)

Source: `03_FINDINGS/shop/_ui-parity.md`. Baseline: the mockup at `localhost:6100`, shop
*Beauty Center*, identity Ghada Al-Balawi, simulated clock 19 May 2026. Implementation:
`stageshops.navagoo.com`, real clock 6 Aug 2026. Single browser session, viewports
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* rather than as findings. Everything on
`01_BASELINE/demo-artifacts.md` was subtracted before writing.

**Result as recorded: 29 findings — 1 S2, 17 S3, 11 S4. No baseline screen was absent.**

> **A counting discrepancy inside the source, stated rather than resolved.** The `_ui-parity.md`
> headline records **29** findings (1 S2 · 17 S3 · 11 S4). The enumerated set in the same file
> carries **32** ids — `F-SHP-UI-01` … `F-SHP-UI-32`, of which **1 is S2, 20 are S3 and 11 are S4**.
> The S2 and S4 counts agree; the S3 count does not. **This report states the headline figure as
> recorded and `BACKLOG_SHOP.md` carries all 32 enumerated items, so nothing is dropped in either
> direction.** Reconciling the two is a re-count task, not a reporting one. Nothing in §7 or in the
> verdict turns on which figure is right: the severity *shape* — one S2, everything else S3 or S4,
> no S1 — is identical on both counts.

**The portal is a close, disciplined port, and the divergences are concentrated inside modals**
rather than at screen level. Screen-level IA, tab sets, filter toolbars, status vocabulary and the
whole Analytics / Plans / Finance widget inventory match the baseline. The single S2 is
**F-SHP-UI-17** — the nav slot at position 3 is labelled `Service Bundles` and routes to a
**catalogue** surface, where the baseline mounts an **owned-package operations hub** (one row per
package a customer has bought, with sessions-remaining and a redemption-visits drawer). It is the
UI face of PKG-09.

### 7.1 Design tokens are identical

Sampled with `getComputedStyle` on the equivalent element in each codebase, and reported as the
browsers' own computed values. Badge colours were read from live status pills.

| Token | Element sampled | Mockup | Portal | Match |
|---|---|---|---|---|
| Page heading colour | `main h1` | `rgb(31, 22, 41)` | `rgb(31, 22, 41)` | match |
| Heading size / weight | `main h1` | `24px / 800` | `24px / 800` | match |
| Body-muted colour | page subtitle `p` | `rgb(111, 118, 130)` | `rgb(111, 118, 130)` | match |
| Subtitle size / weight | page subtitle `p` | `14px / 400` | `14px / 400` | match |
| Primary button fill | `New walk-in` | `rgb(89, 66, 121)` | `rgb(89, 66, 121)` | match |
| Primary button label | `New walk-in` | `rgb(255, 255, 255)` · `15px / 600` | identical | match |
| Primary button radius | `New walk-in` | `1.67772e+07px` (`rounded-full`) | `9999px` | equivalent |
| App background | `body` / `main` | `rgb(244, 247, 247)` | `rgb(244, 247, 247)` | match |
| Font stack | `body` | `"Google Sans Text", Alexandria, Barlow, system-ui, -apple-system, "Segoe UI", Roboto, saudi_riyal, sans-serif` | identical string | match |
| Badge · Scheduled | list status pill | `#5C329D` on `#EAE3F5` | identical | match |
| Badge · Completed | list status pill | `#4C9686` on `#E7F5F1` | identical | match |
| Badge · Collect due | collection pill | `#C54B10` on `#FCE8DD` | identical | match |
| Badge · Collected | collection pill | `#2A9F7D` on `#E2F6F0` | identical | match |
| Badge · Settled | settlement pill | `#066FAA` on `#DCEEF7` | identical | match |
| Badge · Pending | settlement pill | `#576278` on `#E9ECEF` | identical | match |
| Badge · Group | id-cell pill | `#46345F` on `#E4DAF4` · `10px / 700` | identical | match |
| Badge metrics | all status pills | `12px / 600`, pill radius | identical | match |

**Every value sampled is byte-identical between the two codebases — 14 of 14, including the full
status-badge ramp.** Unlike the admin pass, which found one token-level divergence (the primary CTA
corner radius), **the shop portal produced none**: the shop CTA resolves to `9999px`, equivalent to
the mockup's `rounded-full`.

Two badge tones present in the mockup were not reachable in staging data (`Cancelled`
`#B94A4E`/`#FAE7E7`, `In Progress` `#CE991D`/`#FFF5DF`) and two portal tones have no mockup
counterpart sampled (`No balance`, reusing the slate pending token, and `No Show`
`#434958`/`#E6E7E9`). Those four are *not compared*, not *matched*.

### 7.2 RTL passes structurally, with one real defect

Sampled at 1440 on **Bookings ▸ Day**, the **New-booking modal**, **Services**, plus the shell on
every screen visited in Arabic — four surfaces including one modal, with a like-for-like mockup
capture of the day view.

Direction flips cleanly on all of them: the sidebar moves right, the calendar time gutter moves
right, the zoom rail moves left, headings and table cells right-align, the modal's close control
moves to the top-left, and the modal footer button group mirrors with the primary at the correct
end. Nav labels, tab labels, page titles, status chips, form labels, placeholders and the invitation
templates are all translated. Prices render with Arabic-Indic digits on the Services cards.

**The one real defect is time rendering — F-SHP-UI-27 (S3).** The baseline renders times as
`٩:٠٠ ص` (Arabic-Indic digits with the Arabic meridiem) and the date as
`الثلاثاء، ١٩ مايو ٢٠٢٦`. The portal's calendar gutter renders `AM 12:00`, `PM 1:00` — **Latin
digits with an untranslated English meridiem, which bidi then reorders so the meridiem leads.** The
top-bar clock reads `PM 4:47:09` for the same reason. Numeral systems are also mixed within one
page: the top bar shows `٦ أغسطس ٢٠٢٦` while the New-booking date chip on the same screen shows
`الخميس، 06 أغسطس 2026`. **This is one defect with three surfaces, not a layout mirroring failure —
no mirroring defect was observed anywhere.**

Two defects filed elsewhere also reproduce in Arabic and are deliberately not re-counted:
category-badge truncation (F-SHP-UI-19) and the empty modal header with dead panel space
(F-SHP-UI-07).

### 7.3 The substantive divergences are modal-level

Four are worth stating in full:

- **The booking is committed on entering the pay-now sub-step (F-SHP-UI-03, S3).** The baseline's
  `payNowStart()` only switches phase; the row is created inside `collectAndCreate()`, i.e. *after*
  a payment method is chosen. In the portal, pressing `Pay now` persists the booking immediately —
  a run that opened the sub-step and dismissed it with Escape left `NB-QC-260810015` on the calendar
  in *Scheduled / Collect due*. This is the UI face of CF-CC-05's flow.
- **`Book now` and the `Earliest` badge do not render inside the New-booking modal (F-SHP-UI-04,
  S3).** Verified twice — today with one open slot, and 7 Aug with 31. **The same picker inside the
  Reschedule modal renders both correctly**, so the divergence is confined to the create path.
- **Every booking modal is an iframe inside a `.modal-panel` shell (F-SHP-UI-07, S3).** The outer
  shell renders an **empty `h3` title bar** (~60 px, containing only the close control) above the
  iframe, and the iframe is fixed at `h-[70vh]` / `h-[82vh]` regardless of content — ~400 px of
  blank panel below the booking-detail content at 1920, ~200 px on the Arabic New-booking modal. On
  the promotion modal the outer bar reads `New promotion` while the iframe body carries a second,
  differently-worded heading `Create Promo Code`.
- **The riyal-glyph markup is double-escaped and printed as literal text (F-SHP-UI-01, S3).** One
  occurrence on the Home trial banner; **eight** on the Settings ▸ Notifications price chips, where
  the literal string is **wider than its chip and escapes the container**, overprinting the
  neighbouring `WhatsApp` button and running past the table's right edge, so the paid-channel
  controls are partly illegible.

Also recorded: the detail **modal** is 768 px at 1440 and stacks to one column where the baseline
uses ~1150 px and three columns — **the same body renders three-column in the inline list drawer at
1440, so the constraint is the modal shell** (F-SHP-UI-14); the Finance earnings drawer starts at
viewport `y=0` so its header renders **underneath the fixed top bar** (F-SHP-UI-25); and four date /
time formats coexist for the same booking across the list, the detail body, `Booked on` and
Finance ▸ Settlement (F-SHP-UI-15).

### 7.4 Coverage limit — state this wherever the UI findings are quoted

**UI parity is established for the 51 surfaces compared and for no others.** Of the 151 inventoried
baseline surfaces, 100 were not opened on both sides. Specifically **not compared**:

- **Group bookings end-to-end** — the party drawer's whole-party action bar and all three party
  modals. A group row exists in staging (`GRP-2608-ALNJM`) and one guest's detail was opened.
- **Calendar interaction states** — drag-in-progress snap preview, drop-rejected variants, the
  drag-confirm modal, `Block time` / time-off create and remove, fullscreen, zoom extremes. The drag
  plumbing and its confirm copy were read from the DOM but not exercised.
- **Booking-detail edge states** — cancelled branch with refund line, no-show branch, the
  grace-window-disabled tooltip, the per-row actions menu, the notification-bell contents.
- **Finance** — `Detailed charges` and `Invoices` tabs, invoice pay/view, the payment-method picker,
  the package-sale drawer, print layouts.
- **Team** — Payroll and Structure tabs and their eight modals; the delete / deactivate /
  has-bookings confirms.
- **Services** — Routines, Add-ons, Bundles and Packages tabs (all empty or near-empty in staging);
  the freebie modal, image cropper, variant picker, and edit-mode of the catalogue modal.
- **Customers** — Classifications and Freeze-list tabs and the freeze add/remove modals.
- **Plans** — confirm, add-card, cancel-confirm and invoice-view modals; the saved-cards and
  billing panels below the fold.
- **Home dashboard panels** — only the trial banner and the KPI band were examined; the schedule
  hero, attention feed, balance panel, week bars, activity feed and review-reply modal were not.
- **Shell** — account-settings modal, notifications bell dropdown, the notifications screen, terms
  gate, setup wizard, subscription lock, collapsed sidebar.
- **1280** was checked only on the bookings list (the column-drop boundary).

**Confidence caveats that must survive.** **F-SHP-UI-16** (a service line's amount not summing to
the total) is reported at **low confidence** — it may be a data artefact of the 1.00 test service
rather than a rendering fault. **F-SHP-UI-17 is 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).**
The absent `Staff logins` tab is **not-comparable** — the baseline gates it on a permission that
could not be read from the portal.

---

## 8. The owner's own observations

Four hypotheses were carried into this phase from the product owner's own use of the portal, plus
two re-tests from the admin phase. Full record: `03_FINDINGS/shop/_hypotheses.md`, with screenshots
in `02_EVIDENCE/shop/hypotheses/`.

A note on the baseline's authority applied throughout: the mockup is a front-end-only artefact with
no server. It is authoritative for **what the screen offers the user**; it cannot be authoritative
for **whether a network call reaches a payment provider**.

### H3 — the specialist editor lacks the auto-translate affordance · CONFIRMED, different mechanism · S3

The mockup's *Edit specialist* dialog exposes three bilingual pairs — **Name**, **Title**, **Bio** —
each with its own translate button (6 in the dialog, counted in the DOM). The portal's editor
exposes one `full_name` input, no Title field at all, and one `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 auto-translate system is
live in this portal** on surfaces whose columns *are* bilingual: `/shop-service/update` renders
Service name as an AR/EN pair with the same hint and button.

**This is not a UI omission.** `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 — a schema and API-contract change, not
a view edit. **Recommend routing to the API/mobile owner rather than the portal backlog.**

### H4 — the specialist editor lacks the image picker with preview/adjust · CONFIRMED · S3

The mockup offers a framed preview with inline remove, a live *"IN THE CUSTOMER APP"* strip showing
both shapes the app uses, and a dedicated **Adjust image** modal with a drag canvas, circular
app-crop overlay, Zoom slider, Reset and Cancel/Apply. Verified by actually uploading a file. The
portal's `trntv/filekit` widget produced an immediate AJAX upload on selection and a thumbnail with
a remove control — **no crop, zoom, reposition, reset, apply gate or app-shape preview.** Pure
front-end; the stored artefact is a single image path either way, so a cropper can be added
client-side without touching the API contract.

### H6 — subscription checkout does not reach a live payment rail · CONFIRMED · S2, environment not build

Three independent runtime observations on staging: the rendered plans page ships
`paymobTokenizationConfigured = false`; `POST /earnings/card-token-start` returned
`{"success":false,"message":"Card tokenization is not available right now."}`; and the shop's saved
card (Visa ending 1234) carries a locally generated placeholder token 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"*.

**This is an inference, and is stated as one.** Reaching `recordSubscriptionCharge()` with a
non-zero amount requires ending the shop's live free trial, which would stamp an irreversible PAID
ledger row, and no second shop-portal login was available. The branch is established from the code
plus the three probes, **not from a completed charge.** The observed end-to-end run (Upgrade to Pro)
took the free-trial branch, which is a plan swap with no charge — it exercises the checkout UX, not
the money path.

**The gateway integration exists, is shaped for the real flow, and degrades rather than errors when
unconfigured. What is missing is staging configuration.** The residual product risk worth flagging:
with the fallback active a subscription reads **PAID** on the ledger without money moving — fine on
staging, but 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.

### H5 — re-test: a shop reaches features its plan would not include · PARTIALLY REFUTED — remediation has landed

**This is the clearest positive movement between the two audit phases and should be read as such.**
At the admin pass, `EntitlementService::shopCanAccess()` defaulted to allow and **had no callers**,
so no tier gate was reachable from the portal. Since then `frontend/components/EntitlementFilter.php`
has shipped, attached at **application** level via `'as entitlement'` in
`frontend/config/web.php:93-94` — so controllers overriding `behaviors()` without merging the parent
cannot bypass it. It applies a `FEATURE_MAP` (packages, analytics, promo, invitations, social-media,
branches) routed through `shopCanAccess()`, plus a `GO_LIVE_MAP` blocking the booking calendar and
walk-in creation for a shop that has never held a subscription.

**Verified live.** Beauty Center is on **Growth**; `multi_branch` is in `PRO_DELTA`. Navigating to
`/branch` returned the interstitial *"This feature isn't included in your current plan · Current
plan: Growth · View plans"* instead of the Branches screen — HTTP 200, aurora layout, escape hatch to
`/navagoo-plans` intact. **Tier gating is enforced.**

**What remains open, and by design.** `shopCanAccess()` keeps `$defaultAllow = true`, so a shop with
**no** subscription row still passes every `FEATURE_MAP` check — documented as a deliberate
live-rollout posture, narrowed by `GO_LIVE_MAP` so such a shop still cannot take bookings. That is
recorded as SN-03 (S3). **The no-subscription half could not be verified end to end** — the admin
Subscription Dashboard lists shops with no subscription (`NSH-251210004`, `NSH-251110003`) but no
portal credentials for them were available. **BLOCKED** for that half; the code path is unambiguous.

### H7 — re-test: SMS/WhatsApp notification triggers reach a provider · UNCHANGED, still CONFIRMED

`NotificationDispatchService::emit()` (`:830-880`) constructs and saves a `Notifications` row and
returns. It calls no provider client on any channel; `SMSHelper`, `WhatsAppHelper`, Twilio and
Msegat appear **zero** times in the file, and its own docblock states *"The actual provider send
(Msegat/Twilio) is still a separate integration — no gateway is wired yet."* Billing runs
regardless: a paid-channel send past the free allowance appends a `Charge` via `billPaidSend()`.

**Precision worth preserving.** Provider gateways **do** exist elsewhere and are wired — Msegat and
Twilio for OTP, and the T2 WhatsApp API for invitation campaigns. **The gap is specific to the
notification-trigger dispatch path, not to the platform's messaging capability.** Reporting it as
"no SMS/WhatsApp anywhere" would overstate it.

**This is already 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?"). It is cross-referenced from `BACKLOG_SHOP.md`
rather than duplicated. The shop-side observable is consistent but not decisive: Settings ▸
Notifications shows *SMS 0 / 50 free* and *WhatsApp 0 / 20 free* with SMS selected as the paid
channel on *Appointment Completed* (4 events this month) and the counter still at 0 — which could
also read 0 because the channel selection post-dates those events, or because `channelUsable()`
gated it. The decisive evidence is the absence of any provider call in `emit()`.

---

## 9. Structural observations

These span findings and are more actionable than any single one.

### 9.1 Two finance rails coexist, and the shop's numbers come from the older one

This is the single most consequential pattern in the audit. The `charge` ledger
(`FinanceLedgerService`) and the legacy `Earnings` / `shop_earning` engine both exist and are both
live. The ledger implements the baseline's formulas; the legacy engine is what the shop is shown and
paid on.

| Finding | Sev | Ledger side | Legacy side |
|---|---|---|---|
| F-FIN-02+03 | S1 | no `marketing_fee` row is raised at completion | `Earnings::calculateNavagooMarketingFees` charges `platform_commission%` ungated |
| F-FIN-10 | S1 | `TYPE_PROCESSING_FEE` never consulted | payout built from `shop_earning.net_collectible_amount` |
| PKG-03 | S1 | `bookingRevenue()` has no package guard | `ensureEarnings` mints Payment + Earnings + ShopEarning for a 0.00 collection |
| F-FIN-16 | S2 | the four correct per-booking methods exist | `buildEarningsRows` computes fees from `navagoo_marketing_fees + vat_navagoo` |
| F-FIN-08 | S2 | `shopBalanceView` clamps at zero | the Earnings tile's *Owed to Navagoo* branch tests the legacy sum, which never nets fee rows |

**The observable consequence is stated in F-FIN-02+03's own worked example: on one navagoo-sourced
460 booking, the same Earnings screen shows Navagoo fees of 19.67 in its tile and 23.00 in the row
beneath.** Five findings, one structure. Any remediation plan that treats them as five independent
tickets will fix the ledger side and leave the shop's numbers unchanged, because the shop's numbers
do not come from the ledger.

**A caveat the admin phase established and this phase confirms:** `REPORT_ADMIN.md` §6 (S1-5) warns
against pointing marketing-fee remediation at the payout rail for exactly this reason. The two
phases agree.

### 9.2 The package-redemption defects cluster because the portal cancel paths never call helpers that exist

Four of the eleven S1s (PKG-01+02, PKG-03, PKG-05, CF-CC-02) and four of the S2s (PKG-04, PKG-06,
PKG-07, PKG-08) reduce to one absent concept: **nothing on a 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`.**
- Both portal cancel paths — `BookingController::actionCancel` (`:708-811`) and
  `AgentsBookingsController.php:331` — were read in full and neither calls either helper. **There is
  no model hook acting as a safety net.**

**The working implementation exists in the tier that is out of scope.** That is a scheduling fact
worth stating plainly: this is not four features to build, it is one guard to introduce at the
portal's write paths, plus two existing helpers to call. It is also why the api tier's
customer-initiated cancel reinstates the session correctly while the shop-initiated cancel does not
— **the same customer, the same package, two different outcomes depending on who cancels.**

### 9.3 One completion chokepoint calls one of the two things it needs to

`BookingCompletionService::ensureEarnings()` calls `ensureProcessingFee()` and nothing else
(`:75-79`). That single omission produces:

| Finding | Sev | What is not raised at completion |
|---|---|---|
| F-FIN-02+03 (ledger side) | S1 | the `marketing_fee` charge row |
| CLS-02 | S2 | the customer-classification row |
| F-FIN-07 | S2 | (adjacent) `ShopEarning` on the cancel/no-show paths, so they never enter the settlement pool |
| GRP-02 | S3 | charge parity between a group child and an equivalent standalone booking |

CLS-02's consequence is the least obvious and worth stating: because no completion path classifies,
**the classification row is first written by whichever later event runs first — a cancellation, an
admin override, 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 the freeze list 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.

### 9.4 A value is stored, editable and persisted — and unread

The admin phase named this pattern (`REPORT_ADMIN.md` §7.1) and it recurs here at the same rate. Ten
shop findings share the shape: a column or constant exists, is written, and no consuming path reads
it.

| Finding | Sev | Stored / defined | What the engine actually uses |
|---|---|---|---|
| CF-CC-02 | S1 | `Booking::getIsPackageRedemption()` | nothing — zero call sites |
| SN-02 | S2 | `shop.time_format` | `date('g:i A')` and fixed-format helpers everywhere outside the Settings pane |
| SN-01 | S2 | plan `sms_included` / `wa_included` | `CommercialConfig` global, else the 50 / 20 constants |
| F-FIN-06 | S2 | `NavagooOffer.rate_discount_pct` + `OfferPricingService::rateDiscountPct` (unit-tested) | undiscounted `marketingRatePct` / `processingRatePct` |
| CF-CC-06 · SN-04 | S2 | `mada_apple_pay` / `deposits_pos` feature keys | `ShopPaymentSettings::getEnabledModes()` alone |
| CLS-03 | S3 | `customer_classification.mobile` | `findOne(['shop_id','customer_id'])` with no mobile fallback on the money-gating reads |
| SN-05 | S3 | `sms_approval_status` / `wa_approval_status` (backfilled by `m260728_100100`) | the single per-trigger `approval_status` |
| BE-F13 | S3 | four snapshot columns on `booking_service` | left null by the create loop; per-line price re-read from the live catalogue |
| BE-F14 | S3 | `booking_transition_config.rescheduleStatuses` | hard-coded `isDraggable() = [SCHEDULED, ACCEPTED]` in both move writers |
| CF-CC-07 | S3 | three `walkin_*` columns, rendered as checkboxes | dropped by `scenarios()` mass assignment; never persisted |

**The operational consequence is uniform: the portal gives no signal when a saved value is inert.**
A shop owner changes a setting, the form reports success, and the behaviour does not move. CF-CC-07
is the sharpest case — the checkboxes render, accept a click and save without error, and the columns
are unchanged.

**Three of these are documented as deliberate deferrals in the code itself**, which is a materially
different situation from an accidental omission: `m260728_100100`'s docblock states that wiring the
per-channel approval flags is a follow-up; `CustomerClassificationService`'s docblock records the
deep-link proxy (CLS-01) as deferred pending the api signup flow persisting the invite token; and
`EntitlementService::shopCanAccess()` documents its permissive default as a live-rollout posture.

### 9.5 Two portal write paths bypass an FSM the portal itself defines correctly

`BookingTransitionService::defaultTransitions()` is byte-equivalent to the baseline map (C-SHP-002,
**pass**). Two paths do not consult it:

- `AgentsBookingsController::actionUpdateStatus` writes `$model->status` from the request with only
  a cancel block and an earlier-open-booking rule (BE-F04 / CF-CC-01, S2).
- `GroupBookingService::startGroup / cancelGroup / cancelParticipant / completeGroup` assign
  `Booking::STATUS_*` directly and save with validation off (GRP-04, S2), so `completeGroup()`
  stamps a **scheduled** guest COMPLETED without passing through `in_progress` — a move the solo
  path rejects — and the cancel paths cancel an `in_progress` child, which it also rejects.

**BE-F04 / CF-CC-01 was examined for promotion to S1 and deliberately held at S2.** The code says
what the finding claims, **but `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. That reasoning must travel with the
finding.

### 9.6 One configured floor, three different fallbacks

`WithdrawalBundlingService::DEFAULT_MIN_WITHDRAWAL = 5000`, `EarningsController::actionIndex`
hardcodes `5000`, and `FinanceLedgerService::shopBalanceView` falls back to `50.0`, while
`FinanceLedgerService::minWithdrawal` additionally reads `CommercialConfig.min_withdrawal_sar`,
which the bundling service does not consult (F-FIN-15, S3). **The dashboard balance panel and the
Earnings tab can therefore disagree about whether the same shop meets its withdrawal minimum.**

---

## 10. What is working

Reported with the same rigour as the defects. A report that only lists problems is not an audit.

### Design tokens are identical — with no exception found

14 of 14 sampled values byte-for-byte, **including the complete status-badge ramp** — Scheduled,
Completed, Collect due, Collected, Settled, Pending and the Group pill, each matching on both
foreground and background hex, at `12px / 600` with pill radius. The admin pass found one
token-level divergence (CTA corner radius); **the shop pass found none.** §7.1 has the table.

### RTL is structurally correct

Four surfaces sampled including one modal, with a like-for-like mockup capture of the day view. The
shell mirrors, the calendar gutter and zoom rail swap sides, headings and cells right-align, the
modal close control and footer button group mirror correctly, and every label, tab, title, chip,
placeholder and invitation template is translated. **No layout mirroring defect was observed
anywhere.** The single real defect is time-string localisation (§7.2).

### The overnight business-day coordinate model reproduces the baseline's worked example exactly

**C-SHP-008 · pass.** With `openTime = '11:00'` and `closeTime = '02:00'`, a booking at
`2026-05-20T01:00` resolves `businessDayOf = '2026-05-19'` and the window is
`{startMin: 660, endMin: 1560}` — the baseline's own worked example, reproduced by
`BookingScheduleService.php:140-171, :240-251`. **This is the same subsystem that carries BE-F03,
and the distinction matters for remediation:** the *forward* conversion and the day-window model are
correct; only the *inverse* (business minute to calendar date) fails to roll the date, and only at
two live call sites.

### The transition FSM's default map is byte-equivalent to the baseline

**C-SHP-002 · pass.** `BookingTransitionService::defaultTransitions()` (`:44-54`) implements exactly
`scheduled → [in_progress, no_show, cancelled]`, `in_progress → [completed]`, and `completed` /
`no_show` / `cancelled` as terminal. `scheduled → completed` is forbidden, as required. **The
canonical map is right**; §9.5 records the two paths that do not consult it.

### Twenty-five contracts match the baseline exactly

Beyond the two above, the passing set includes the load-bearing finance formulas — **C-SHP-025**
(the processing fee is non-refundable), **C-SHP-026** (marketing fee = ex-VAT booking value ×
rate%, floored at the minimum), **C-SHP-029** (the marketing fee stays pending until the booking
reaches a locked state), **C-SHP-031** (refund-zone resolution from the shop's cancellation policy)
— the attribution walk (**C-SHP-044**, **046**, **048**), five group invariants (**C-SHP-049**,
**051**, **052**, **053**, **056**), the collection-status derivation (**C-SHP-070**), the tip model
(**C-SHP-072** — card-only, treated as a liability rather than shop revenue), the scheduling window
and platform slot lock (**C-SHP-076**), and both notification arithmetic contracts (**C-SHP-079**,
**080**). Five further booking-engine contracts pass on the placement side — **C-SHP-010**
(cancelled bookings do not occupy the calendar), **012**, **014**, **020**, **021**.

**The fee formulas themselves are not what is wrong.** In several S1s the arithmetic is correct and
the call site is missing or the input is wrong — a different and generally smaller class of work
than rebuilding a fee engine.

### Screen-level parity is close, and several surfaces are exact

The Services screen is a near-exact port down to the five-tab segmented control with counts, the
`Show inactive` switch, and the card anatomy — thumbnail, category badge, four lifecycle icons,
struck base price beside the effective price, the price + VAT split, linked-specialist footer. The
Cancel, Reschedule and Collect-payment modals match **without exception** on title, subtitle, refund
zone, placeholder copy and disabled-primary behaviour. Plans, Analytics (11 widgets, RAG target
lines, on-time radials, the weekday-by-hour heatmap) and Settings (the exact five tabs) all pass.
The bookings list matches column-for-column **including responsive column dropping at the baseline's
own breakpoints** — `Specialist` hidden at 1024 and restored at 1280+, `Settlement` hidden below
1536 — with no horizontal overflow at any of the four widths tested.

### Nav IA matches, footer treatment included

Ungrouped Home, then Operations / Money / Growth / System with the baseline's own membership and
order, and the footer treatment (Plans and Settings pulled out of the grouped list, collapse
chevron) matching exactly. The two structural differences are a fifth section header (`More`)
carrying a portal-only `Reviews` item — recorded as *richer data*, not a gap — and the
`Packages` → `Service Bundles` relabel, which is F-SHP-UI-17.

### The Finance surfaces reproduce the baseline's own money vocabulary

Earnings carries all four stat tiles **including the negative-balance state** (`−10.40` /
`Owed to Navagoo`), the in-store cash/card strip, and eligibility / settlement / collection badges.
The booking drawer reproduces the P&L waterfall — revenue, VAT split, `collected in person`,
itemised Navagoo fees **with their basis** (`2.5% + 1.00 · online`), fee VAT, and the
`Settles via Navagoo → Navagoo payout` block. Settlement reproduces the colour-coded formula strip
`COLLECTED + TIPS − MARKETING − PAYMENT PROCESSING − FEE VAT = NET PAYOUT`, the min-withdrawal
caption and the eligibility table.
**One qualification:** F-FIN-08 records that `shopBalanceView` clamps at zero, so the *dashboard*
balance panel and the analytics settlement strip cannot render a negative position; the Earnings
tile has the branch and renders it, but the value it tests is the legacy sum. The surface is right;
one of its inputs is not.

### Tier gating shipped between the two audit phases and works

See §8, H5. Verified live on a Growth shop against a `PRO_DELTA` feature.

### Verification discipline held

**Seven candidate findings were refuted and dropped rather than downgraded**, and the reasoning is
preserved in each area file. Three are worth naming because they show the refutation cutting toward
the portal:

- **GRP-07** was refuted because *the stated baseline was wrong* — the mockup does **not** complete
  a party partially when money is owed (`GroupBookingsView.tsx:71-78` toasts and opens the collect
  modal instead, and `groupBooking.test.ts:156-165` confirms no partial completion). The portal
  behaves the same way; the difference is a server error message versus a toast plus auto-opened
  modal, which is presentation.
- **F-FIN-12** was refuted because the finding misread the mockup's VAT-inclusive discount base. On
  the finding's own example — 50 off an inclusive 230 at 15% — the mockup also yields 180 inclusive
  / 156.52 base, which is exactly what the portal produces.
- **F-FIN-04** was refuted on arithmetic: every money view excludes pending rows, so a
  creation-time marketing row never reaches settlement, costs-to-date or the balance, and its
  genuine residue is already reported as F-FIN-05.

Additionally, two S1 candidates were downgraded, three merges collapsed double-counted ids, and
**PKG-04's evidence was rewritten because it contradicted PKG-03** — as originally written the two
asserted opposite things about the same money.

---

## 11. Deviation / gap / drift split

Per spec §4.1, every finding carries a cause. Tallied across all 60 confirmed findings:

| Cause | Count | Share |
|---|---|---|
| **Deviation** — implemented, behaves differently from the baseline | 43 | 72% |
| **Gap** — present in the mockup before porting began, not present in the portal | 17 | 28% |
| **Drift** — added to the mockup after the corresponding port | **0** | 0% |

And by cause of *absence* (spec §4.2), which determines the kind of work each needs:

| Cause of absence | Count | Share |
|---|---|---|
| **Built, not working** — code exists and executes, produces the wrong result | 30 | 50% |
| **Not built** — no implementing code exists | 15 | 25% |
| **Built, not configured** — code exists but is inert in this environment | 11 | 18% |
| **n/a** — recorded divergence with no absence dimension | 4 | 7% |

**Reading.** Nearly three-quarters of findings are deviations and half are built-not-working: **this
is a body of code that exists and runs, diverging at specific decision points.** The proportion is
more pronounced than the admin phase's (63% deviation / 44% built-not-working), which is consistent
with §9.1 and §9.4 — most of the money findings are a rail that computes correctly and is not the
one read, or a value that is stored and not read. Only a quarter is never-built work, and it
concentrates in packages (5 of 8 findings) and in the notifications/settings surface.

**On the zero.** No confirmed finding was classified drift. That is the recorded state, not a proof:
`releases.ts` dating was available as a method (spec §4.1) but no per-finding release date is
recorded, so "0 drift" means *no finding was identified as post-port mockup movement*, not *no
post-port mockup movement exists*. If any backlog item is contested on the grounds that the mockup
moved after the port, `releases.ts` is where that is settled.

---

## 12. Coverage and limitations

An honest boundary is worth more than implied completeness. What follows is what this audit does
**not** establish.

### 12.1 What was not examined

- **Track U reaches 51 of 151 inventoried surfaces.** §7.4 lists what was not opened, area by area.
  **No claim is made about the other 100 in either direction.**
- **Loading and error states are deliberately not graded.** The mockup reads a synchronous Zustand
  store and renders no spinners, skeletons or suspense fallbacks, and has no network layer and
  therefore no request-failed or offline surface. **There is no baseline to grade against.**
  Validation and rejection states *are* graded, because the mockup produces them.
- **The `api/` tier is out of scope** per spec §2, and this matters in one direction that must not
  be misread: several correct implementations live there (the package reinstate/reverse path,
  `amount_collected` assigned explicitly at creation, `recordRedemption`). **Their existence is
  evidence about where the working code is, not a finding about the api tier.**
- **Permission-gated shop surfaces were not exercised.** Only one shop-owner session was available,
  so the `Staff logins` tab, the non-finance-role layouts of Home and Analytics, and every
  permission-gated affordance in the baseline inventory were not reached.
- **The no-subscription entitlement branch could not be verified end to end** (§8, H5) — no portal
  credentials existed for a shop with no `shop_subscription` row.
- **Group bookings were graded from code and one guest-detail view**, not from a driven party. The
  four group findings and all 11 group contracts rest on source reading plus the surfaces in §7.4.
- **Only the day view has a like-for-like RTL mockup capture.** The other Arabic observations are
  portal-side against the baseline's known strings.
- **`BOARD_SHOP.html`** — the side-by-side visual evidence board named in spec §10 — is not part of
  this delivery. Screenshots are in `02_EVIDENCE/shop/`; treat the directory rather than a count as
  the reference.
- **Track D (documentation currency) has not been run.** It is a Phase C deliverable.

### 12.2 The 35 outstanding probes

Thirty-five probes across **31 distinct contracts** carry findings derived from source reading that
still want runtime confirmation: 6 each in `booking-engine`, `shop-finance` and `collect-complete`,
5 in `classification`, 4 each in `group-bookings`, `packages` and `settings-notifications`. Each has
a written recipe and a stated confirm/refute condition at the foot of its area file, and several
state the condition that would **withdraw or narrow** the finding — C-SHP-024, for example, records
that `amount_collected = 0` with no processing row "would mean the fallback is not reachable in
practice and the finding drops to the api-created case only." **Those withdrawal conditions are part
of the finding and must not be dropped when a ticket is written.**

Two probes are blocked rather than merely unrun. **C-SHP-007** could not be settled from the files
in scope: whether freebie minutes are folded into `ShopService.service_period` at save time
determines whether every occupied block for a service carrying a freebie is short by those minutes.
**C-SHP-073** (the CF-CC-05 pay-now probe) requires a shared browser or curl session that was not
available when the finding was written; the finding was subsequently corroborated by code read plus
the UI pass's F-SHP-UI-03 observation, but the network-response half is unconfirmed.

### 12.3 Preconditions that will make a naive probe read zero

**This is the most important operational caveat in the report.**

- **Commercial rates read zero across staging for most of this audit.** A probe raised them mid-way
  and left them raised, but the ledger these findings were written against accumulated under zero
  rates: as recorded by the admin phase's live-ledger probe, both processing-fee rows in the entire
  ledger read `0% + 0.00 of collected` and charge 0.00, and the ledger contained **no
  `marketing_fee` row at all**. **Fee arithmetic cannot be validated on staging without configuring
  rates first** — a probe of any processing or marketing fee returns 0.00 regardless of whether the
  formula is right, and a *fix* verified against zero rates will look like a no-op.
- **Historical zero-rate data does not change when rates are raised.** Rows already written carry
  their stamped rate. A probe must create fresh records after the rate change, not read old ones.
- **The staging shop's free trial is live and `trial_consumed` is already 1**, which is why the H6
  money path could not be exercised (§8).
- **Staging has zero service bundles**, which is why F-SHP-UI-17's populated state is unobserved and
  why its S2 grade carries an explicit downgrade condition.
- **The seeded notification triggers were force-approved for in-app by migration
  `m260721_215500`**, so SN-09's divergence surfaces only for a newly created or re-pended trigger —
  a probe against the seeded set will show nothing.

Every backlog item carries its own precondition line for this reason.

### 12.4 Dormancy and confidence caveats that must survive into any status report

| Item | Why a probe may read nothing today | What activates or resolves it |
|---|---|---|
| F-FIN-06 (S2) | No enrolled offer carries a non-zero `rate_discount_pct` | An admin creating any fee-discount offer. The shop-facing Plans UI already promises the behaviour. |
| BE-F03 (S1) | Requires `close_at <= open_at` | Any shop configured with an overnight window — a supported configuration |
| BE-F14 (S3) | The config gate and the hard-coded gate **agree at the default configuration** | Widening `rescheduleStatuses` surfaces the action in the UI while the server continues to reject it |
| SN-09 (S4) | Seeded triggers were force-approved for in-app | A newly created or re-pended shop trigger |
| CF-CC-01 / BE-F04 (S2) | `update-status` has **no caller** in any view or JS | Reachable only by a hand-crafted POST from an authenticated portal user against their own booking |
| H6 (S2) | Established by inference from code plus three runtime probes, **not from a completed charge** | Ending a shop's trial, or a second disposable shop-portal login |
| F-SHP-UI-16 (S3) | May be a data artefact of the 1.00 test service | Reproduce on a normally priced service |
| F-SHP-UI-17 (S2) | Graded on the empty state's create-CTA; staging has zero bundles | A populated `/package` — **if it renders owned rows with sessions-remaining, this downgrades to S4** |
| PKG-04 (S2) | Downgraded from S1; its cause (`gap · not-built`) is rubric-capped at S2, and its proceeds *do* reach withdrawable by the wrong route (PKG-03) | The purchase-time settlement path, which the mockup implements as `packageSettlement` / `isPackageEligible` |
| CLS-01 (S2) | The portal has no deep-link booking source; `viaDeepLink` is proxied by prior invitation-campaign delivery | The api signup flow persisting the invite token at account creation, which the service docblock records as the deferred dependency |

---

## 13. Observed while writing, not verified

Recorded here rather than added as findings, per the no-invented-findings rule. None has been
through refutation.

- **F-FIN-01 (S1, shop-finance) and CF-CC-04 (S1, collect-complete) appear to describe the same
  mechanism.** Both cite `amountCollected()`'s fallback chain, both cite `WalkInBookingService`
  never assigning `amount_collected`, and both carry the identical 460 → 17.10 + 2.57 = **19.67**
  worked example. They were found independently by two area runs against two contracts (C-SHP-024
  and C-SHP-071) and both were upheld at S1 on re-verification. **Whether they should be merged the
  way F-FIN-02+03 and PKG-01+02 were is a grading decision this report does not make unilaterally**
  — §3 keeps them separate, as recorded, and `BACKLOG_SHOP.md` places them adjacent under one shared
  fix so the work is not scheduled twice. If they were merged, the S1 count would become 10.
- **The same `amountCollected()` fallback is already carried in `BACKLOG_ADMIN.md` as FIN-LEDGER-04,
  graded S2 there.** The admin item states a downgrade condition — "if the walk-in creation path
  writes 0, the fallback chain is dormant and this drops to a robustness note." The shop-phase
  evidence bears directly on that condition (`WalkInBookingService` writes neither the column nor a
  zero), but reconciling the two gradings is a re-grading task, not a reporting one.
- **CF-CC-01 and BE-F04 are recorded as twins by the source itself** — each area file says
  "recorded also on the … twin". Both are counted in §3's 60. This is bookkeeping the source
  acknowledges rather than an error, and it is noted so 60 is not read as 60 distinct behaviours.
- **F-FIN-09's only cited file is `backend/controllers/AdminFinanceController.php`** — an admin-tier
  file appearing in a shop-portal finding. The consequence graded is shop-facing (the shop's carried
  balance and the held-earnings figure netted into an invoice), but the fix lands in the admin tier.
  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.
- **PKG-09 carries no `contractId` in the source run** and appears in `packages.md` with
  `(none recorded)` in that field. It is counted and carried, with that noted.
- **`_ui-parity.md`'s headline total and its enumerated set disagree by three S3 items.** The
  headline records 29 findings (1 S2 · 17 S3 · 11 S4); the file enumerates 32 ids
  (`F-SHP-UI-01` … `F-SHP-UI-32`) grading to 1 S2 · 20 S3 · 11 S4. The S2 and S4 counts agree
  exactly, so the difference sits entirely in the S3 band. **This was noticed while writing and has
  not been through any verification pass.** §7 states the headline as recorded; `BACKLOG_SHOP.md`
  carries all 32 enumerated items so that none is silently dropped, and its Counts table shows both
  figures. Which is correct is a re-count of the UI pass, not a re-observation of the portal.
- **Console errors were present on several portal pages** (1–2 per page load) during the hypothesis
  session and were not investigated, as none blocked the flows under test. Two were subsequently
  identified and filed — `ReferenceError: jQuery is not defined` on Settings (F-SHP-UI-31) and 404s
  for two package images on Services (F-SHP-UI-20) — **but the remainder were not enumerated, so no
  claim is made that those two are all of them.**
- **The shop portal's contract pass rate (30.5%) is higher than the admin portal's (20.5%) while its
  S1 count is higher (11 against 7) on fewer contracts (82 against 83).** That is not a
  contradiction — the two instruments measure different things — but it is the kind of pairing that
  invites a wrong one-line summary, so it is stated here rather than left to be discovered.

---

## 14. Where everything is

| Artifact | Path |
|---|---|
| Ticket-ready backlog | `BACKLOG_SHOP.md` |
| Findings summary, S1/S2 index, re-verification record, corrections | `03_FINDINGS/shop/_SUMMARY.md` |
| Contract pass-rate scorecard (Track L) and denominator rule | `03_FINDINGS/shop/_PASS_RATES.md` |
| UI parity (Track U) — token comparison, area-by-area, the UI findings, coverage note | `03_FINDINGS/shop/_ui-parity.md` |
| Per-area findings (7 files, verbatim baseline/observed text) | `03_FINDINGS/shop/*.md` |
| Seeded-hypothesis resolutions (H3, H4, H6, H5/H7 re-tests) | `03_FINDINGS/shop/_hypotheses.md` |
| Baseline surface inventory (151 surfaces) | `01_BASELINE/shop/inventory.md` |
| Behavioural contracts (82, with worked examples) | `01_BASELINE/shop/contracts.md` |
| Demo-artifact exclusion list — nothing on it may be reported as a gap | `01_BASELINE/demo-artifacts.md` |
| Environment, baselines, staging fingerprint, stated limits | `02_EVIDENCE/environment.md` |
| Staging records created during the audit | `02_EVIDENCE/shop/test-data.md` |
| Screenshots (hypotheses, UI parity capture pairs by area, RTL) | `02_EVIDENCE/shop/` |
| The admin phase of the same audit | `REPORT_ADMIN.md`, `BACKLOG_ADMIN.md` |
| Governing spec (classification, severity, writing standard) | `00_SPEC/PARITY_AUDIT_SPEC.md` |
