Parity audit · Evidence board · Phase 2 of 2

Navagoo shop portal — mockup vs. staging

A contract-by-contract comparison of the shop/salon owner portal running on staging against the product mockup, followed by an adversarial refutation pass and an independent re-verification of every top-severity finding. This board carries the verdict, the counts, the eleven S1 findings, the remediation-ordering constraint, the resolution of the owner's own observations, and the visual evidence. The narrative write-up is in REPORT_SHOP.md. It is the second phase of the same audit as BOARD_ADMIN.html and shares its severity rubric and evidence conventions.

Baseline (mockup)
cb48c8d — v0.28.0
React demo at localhost:6100, #/shop/*, identity Ghada Al-Balawi — Owner · Beauty Center
Implementation
Staging shop portal
stageshops.navagoo.com — shop NSH-251210001, plan Growth · free trial
Local portal ref
5efbafc9
branch parity-audit off tailwind-poc
Observation date
2026-08-06
staging Tailwind bundle built 2026-08-03 17:41 GMT
Coverage
82 of 82 contracts evaluated
151 surfaces inventoried · 51 UI-compared on both sides
Still open
35 live probes across 31 contracts
Read before scheduling any finance work

F-FIN-01 and F-FIN-10 must ship together

Fixing the payout omission alone converts a display error into a payout error — every walk-in starts costing the shop 19.67 it does not owe. The two findings currently mask each other's cost, and the masking is what keeps the spurious fee off the money that is actually paid out. The full statement is further down this page.

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. 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 contract pass rate, 30.5%, is ten points above the admin portal's 20.5%.

On the divergence. The behavioural comparison returned 11 S1 and 27 S2 — more S1s than the admin phase found (7) against fewer contracts, and not spread thin: nine of the eleven sit in the completion, collection and package-redemption rails. The concrete shapes are a walk-in charged a 19.67 processing fee on cash that never touched the payment rail; a payout that overpays by the same 19.67 because it never subtracts that fee; a redeemed package session carrying 16.96 of fees against a baseline 0.00; 800 of withdrawable settlement credit minted for four redemption visits on which 0.00 was collected, against a 760 sale; a customer permanently losing 190.00 of pre-paid entitlement when a shop cancels the visit; and cash taken at the counter that the create-then-pay-now step discards without telling the operator. The remaining two admit a booking the baseline rejects.

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. 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.

Three qualifications this verdict must carry. First, the two most expensive-looking findings are coupled and must not be fixed independently. 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

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. Screenshot in Visual evidence.

Scorecard

60 confirmed behavioural findings across seven areas; seven candidate findings were refuted during the adversarial pass and are recorded in the area files rather than reported here. Severity reflects consequence, not effort: S1 is a wrong monetary outcome or destroyed paid value on a reachable path; S2 is a wrong displayed figure or a dead-ended flow; S3 is a behavioural deviation with bounded impact; S4 is presentational.

11
S1
Reachable path, wrong money or destroyed paid value
27
S2
Wrong figure shown, or a flow that dead-ends
17
S3
Bounded behavioural deviation
5
S4
Presentational / label divergence
Area S1 S2 S3 S4 Confirmed Refuted Contracts Pass rate Probes needed
shop-finance36311322119.0%6
packages350080911.1%4
collect-complete341080633.3%6
booking-engine25611412231.8%6
settings-notifications034181837.5%4
classification022150560.0%5
group-bookings0211431145.5%4
Total11271756078230.5%35

Sorted worst-first by severity weight (S1×100 + S2×10 + S3×1). Worst: 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, 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). The pass rate and the severity counts do not order the areas the same way; remediation priority follows severity.

The UI set is scored separately and never blended in

Track U (UI parity) is scored apart from the 82 behavioural contracts, per the audit spec. Result as recorded: 29 findings — 1 S2, 17 S3, 11 S4. No baseline screen was absent. The single S2 is F-SHP-UI-17, the Service Bundles nav slot routing to a catalogue surface where the baseline mounts an owned-package operations hub.

A counting discrepancy inside the source, stated rather than resolved. The headline in _ui-parity.md records 29 findings; 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. The headline figure is stated as recorded and BACKLOG_SHOP.md carries all 32 enumerated items, so nothing is dropped in either direction. The severity shape — one S2, everything else S3 or S4, no S1 — is identical on both counts.

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. Outcome of the re-verification pass: 11 upheld, 0 refuted outright, 2 downgraded, 3 merges. They are grouped so shared root causes sit together — the group heading states what the group has in common. Amounts are reproduced exactly as recorded.

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 F-FIN-01

A pay-on-visit booking reads as fully collected

C-SHP-024deviationbuilt, not working
Baseline

The processing-fee basis is the online amount collected only. amountCollected <= 0 produces no processing row at all, so a pay-on-visit booking carries no processing fee even after completion (finance.ts:118 returns null).

Observed

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; the other two live on earnings/payment, so the chain falls to total_amount, which is never null. 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 the ordering constraint below exists.
Files
  • common/components/FinanceLedgerService.php
  • common/migrations/db/m260608_120100_add_payment_mode_to_booking.php
  • frontend/controllers/BookingController.php
S1 CF-CC-04

The collect path stamps a processing fee on counter cash

C-SHP-071deviationbuilt, not workingCF-CC-08 folded in
Baseline

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

Observed

The collect path calls BookingCompletionService::ensureEarnings, which always calls FinanceLedgerService::ensureProcessingFee. buildProcessingFee resolves its basis through the same fallback chain as F-FIN-01. 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, 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. 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. 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. 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 F-FIN-02+03

One dual-rail defect, two code fixes

C-SHP-027deviationbuilt, not workingmerged
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.

Observed

Side one — the ledger rail goes empty. deriveBookingCharges, the only caller of buildMarketingFee, is reached from four places only; the normal completion chokepoint BookingCompletionService::ensureEarnings calls ensureProcessingFee() and nothing else, so a normally-completed or no-show solo booking never receives a marketing_fee ledger row. Side two — the legacy rail charges ungated. Earnings::calculateNavagooMarketingFees (: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 that column drives net_collectible_amount, which is what the withdrawal actually nets.

Failure scenario — 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: a shop_owned walk-in of 460 with platform_commission 5% gives net_collectible_amount = −23.00; the payout is short 23.00 where the baseline charges zero.

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.
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 F-FIN-10

The payout never subtracts processing fees

C-SHP-041deviationbuilt, not workingordering-coupled
Baseline

netPayout = Σ booking collected + tips − marketing − processing − notif − other fees − fee VAT + Σ package net payouts (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. 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 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.

Do not schedule this in isolation. See the remediation-ordering constraint before writing a ticket for this finding.
Files
  • common/components/WithdrawalBundlingService.php
  • common/models/base/ShopEarning.php

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() and FinanceLedgerService::reversePackageRedemptionCharge() exist and are called only from the api/ tier. That tier is out of scope and is not graded; it is named because it is where the working implementation lives.

S1 PKG-01+02

A redeemed session is charged marketing fees twice, then a third time on cancel

C-SHP-064deviationbuilt, not workingmerged
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. finance.ts:335 returns early on customerPackageId. The guard is first in the charge derivation, so no booking-lifecycle transition can raise fee rows on a redemption visit.

Observed — two code sites

Site one: buildPackageRedemptionCharges() (:1429-1467) has no zero-charge guard and stamps a marketing-fee Charge at redemption time for navagoo_sourced customers. 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 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.

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.
Files
  • common/components/FinanceLedgerService.php
  • frontend/controllers/BookingController.php
S1 PKG-03

A completed redemption mints withdrawable credit for money never collected

C-SHP-065deviationbuilt, 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. finance.ts:508 returns 0 on customerPackageId.

Observed

FinanceLedgerService::bookingRevenue() (:654-660) has no package guard: a completed redemption returns round2(bookingValue), and the shop dashboard's revenue window 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, 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 PKG-05

A shop cancel destroys a paid session and leaves its fee standing

C-SHP-066deviationnot builtdata 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.

Observed

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 → 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.

Files
  • frontend/controllers/BookingController.php
  • common/components/FinanceLedgerService.php
S1 CF-CC-02

A pre-paid redemption cannot be completed without recording money never taken

C-SHP-069gapnot built
Baseline

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

Observed

FinanceLedgerService::outstandingBalance (:1087-1094) has no package-redemption branch, and Booking::getIsPackageRedemption() 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

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

S1 CF-CC-05

The pay-now collection and card tip are discarded without an error

C-SHP-073deviationbuilt, 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.

Observed

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, and BookingTransitionService::defaultTransitions() maps SCHEDULED to [in_progress, no_show, cancelled], so actionCollect's first guard returns success:false. 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.

Probe status: C-SHP-073's network-response half is unconfirmed — it required a shared browser or curl session that was not available when the finding was written. It was subsequently corroborated by code read plus the UI pass's F-SHP-UI-03 observation (a dismissed sub-step left NB-QC-260810015 on the calendar in Scheduled / Collect due).
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 BE-F01

Booking creation never runs the placement check

C-SHP-017deviationbuilt, 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. store.ts:3376-3384 runs checkPlacement before persisting, explicitly to catch programmatic misuse.

Observed

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 BE-F03

An overnight post-midnight slot is persisted against the wrong calendar day

C-SHP-009deviationbuilt, 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'.

Observed — live sites

freeSlots:541 emits the business-day date concatenated with minToClock($start) while $start runs on the business axis up to 1560, 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.

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. 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. A probe against a shop with a same-day window will show nothing. Scope: the forward conversion and the day-window model in the same subsystem are correct and pass (C-SHP-008); only the inverse fails to roll the date, at two live call sites.
Files
  • frontend/components/BookingScheduleService.php
  • frontend/controllers/BookingController.php

The remediation-ordering constraint

Single most consequential scheduling fact in this audit

F-FIN-01 and F-FIN-10 must ship together

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.

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. Each was verified against the live portal with screenshots. 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.

H5 changed during the audit — tier gating now works, verified live

PARTIALLY REFUTED  When the admin phase ran, EntitlementService::shopCanAccess() defaulted to allow and had no callers, so no tier gate was reachable from the portal. A gate has since shipped. frontend/components/EntitlementFilter.php is attached at application level via 'as entitlement' in frontend/config/web.php:93-94, so controllers that override 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 booking-calendar and walk-in creation for a shop that has never held a subscription row.

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.

Still open, and by design: shopCanAccess() keeps $defaultAllow = true, so a shop with no subscription row still passes every FEATURE_MAP check — a documented live-rollout posture, narrowed by GO_LIVE_MAP so such a shop still cannot take bookings. That half could not be verified end to end: no shop-portal credentials existed for a shop with no subscription row.

Observation Resolution What was found
H3
The specialist editor lacks the auto-translate affordance present in the mockup
CONFIRMED — DIFFERENT MECHANISM
S3 · contract-impacting
The mockup's Edit specialist dialog exposes three bilingual pairs — Name, Title, Bio — each side carrying its own translate button (6 in the dialog, counted in the DOM). The portal editor renders one full_name input, one bio textarea, and no Title field at all. A DOM enumeration of the whole form returned zero data-translate-pair attributes and zero inputs whose id ends _en/_ar — the two wiring conventions aurora-translate.js recognises. The script is loaded; it finds nothing to bind to. The same auto-translate system is live on /shop-service/update, where Service name renders as an AR/EN pair with the translate button.

Not a UI omission. user.full_name, user.role and user_profile.bio are single-value columns; no _ar counterpart exists on any specialist/user model. Adding the affordance requires new 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 · not-built, front-end only
Verified by actually uploading a file on both sides, not read from static markup. The mockup offers a 144px framed preview with inline remove, a live "IN THE CUSTOMER APP" strip showing the image in both shapes the app uses, a Change photo button, and a dedicated Adjust image modal — drag-to-reposition canvas, circular app-crop overlay, Zoom slider, Reset, Cancel / Apply. The portal's trntv/filekit widget produced an immediate AJAX upload (POST /agents/avatar-upload → 200) and a thumbnail with an ✕ overlay. No crop, zoom, reposition, reset or apply gate, and no customer-app shape preview — the file is committed to temp storage on selection rather than on Apply.

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.
H6
Shop subscription checkout does not reach a live payment rail
CONFIRMED
S2 as an environment/readiness item, not a build defect
Three independent runtime observations on staging: the rendered Plans page ships var paymobTokenizationConfigured = false;; POST /earnings/card-token-start returned "Card tokenization is not available right now." — the early return at EarningsController.php:937; and the shop's saved card carries a locally generated placeholder token with no gateway or gateway_token. Each independently falsifies the four-part $useRealRail condition, so the executing branch is the dev fallback that appends a PAID Charge row with no socket opened.

The gateway 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. This is an inference — a well-constrained one, but stated as an inference: reaching a non-zero charge required ending the shop's live free trial (trial_consumed is already 1) and no second shop-portal login was available. Residual risk to flag: the same fallback would silently mark production charges paid if the PAYMOB_* keys were ever absent there.
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 records it. Billing runs regardless — a send past the free allowance appends a Charge via billPaidSend().

Precision worth preserving: provider gateways do exist elsewhere and are wired — Msegat/Twilio for OTP, 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.

Visual evidence

This board embeds a curated subset. The shop phase captured 76 screenshots. Sixteen are embedded below, chosen for evidentiary value. The complete set lives in parity-audit/02_EVIDENCE/shop/<area>/ and can be opened directly from disk; every file not embedded is listed by path at the end of this section.

Each pair is labelled: Mockup is the pinned baseline at localhost:6100; Staging is stageshops.navagoo.com. The two sides carry different data and different clocks (mockup 19 May 2026, staging 6 Aug 2026), so compare structure and affordances, not values. Grading is at design-system fidelity — a different DOM reaching the same rendered result passes, and pixel offsets are not defects.

Pay-now sub-step — the missing order summary, and a booking committed on entry

Mockup @ 1440 — baseline Mockup pay-now sub-step showing an order summary — Services, Specialist, Booking value — above the Amount due row and the Cash / Card selector
02_EVIDENCE/shop/booking-modals/shp-booking-new-paynow@1440-mockup.png
Staging @ 1440 — implementation Staging pay-now sub-step showing only Collect payment, Amount due and Payment type, with no order summary above
02_EVIDENCE/shop/booking-modals/shp-booking-new-paynow@1440-staging.png
Look at what sits above Amount due on each side. The baseline renders an order summary — Services, Specialist, Booking value — so the operator can confirm what is being charged before taking money. The portal renders Collect payment ▸ Amount due ▸ Payment type and nothing else (F-SHP-UI-02, S3). Also visible: the baseline's tip field reads Tip for {specialist} (optional) with the settlement note beneath it; the portal's reads Tip (optional), the specialist unnamed and the note absent from the DOM (F-SHP-UI-08, S4). The baseline's money-bearing primary carries the amount before a method is chosen (Pay now · ⃁ 120.00); the portal's does not (F-SHP-UI-09, S4). Callout — the state divergence behind CF-CC-05. In the baseline, payNowStart() only switches phase; the booking row is created inside collectAndCreate(), after a payment method is chosen, and Back returns to the form with nothing persisted. In the portal, pressing Pay now persists the booking immediately — a run that opened this 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-03, S3).

Booking detail modal — the chip strip, the timeline sub-lines, and the 768 px shell

Mockup @ 1440 — baseline Mockup booking detail modal at a wide container, with a labelled STATUS / COLLECTION / SETTLEMENT chip strip below the header and a three-column body
02_EVIDENCE/shop/booking-detail/shp-booking-detail-modal@1440-mockup.png
Staging @ 1440 — implementation Staging booking detail modal in a narrow 768 pixel panel with the body stacked into one scrolling column and no chip strip
02_EVIDENCE/shop/booking-detail/shp-booking-detail-modal@1440-staging.png
Three separate divergences are visible in this one pair. First, the baseline places a labelled status chip strip between the header and the body — STATUS / COLLECTION / SETTLEMENT, each with its tinted pill. No chip strip renders on any portal variant; the header carries customer badges instead and status is only inferable from the timeline (F-SHP-UI-12, S3). Second, the baseline's timeline nodes carry an expectation line — In progress / Expected 3:00 PM, Completed / Awaiting completion. The portal renders the label alone in every state sampled (F-SHP-UI-13, S3). Callout — the shell, not the body, is the constraint. The baseline's detail modal uses the large container (~1150 px at a 1440 viewport) and renders the three-column body at that width. The portal's modal is 768 px at 1440 and stacks to a single scrolling column, reaching 1152 px and three columns only at 1920. The same body renders three-column in the inline list drawer at 1440, which is what identifies the modal shell as the constraint rather than the content (F-SHP-UI-14, S3). Every booking modal is an <iframe> inside a .modal-panel shell; the shell also renders an empty ~60 px <h3> title bar above the iframe and fixes its height regardless of content — ~400 px of blank panel below this content at 1920 (F-SHP-UI-07, S3).

Slot picker — Book now and the Earliest badge, present in one path and not the other

Mockup @ 1440 — baseline Mockup slot picker with a Book now chip above the card and an Earliest badge on the first slot of the earliest open day
02_EVIDENCE/shop/booking-modals/shp-slot-picker@1440-mockup.png
Staging @ 1440 — implementation Staging slot picker inside the New booking modal, with no Book now chip and no Earliest badge on any slot
02_EVIDENCE/shop/booking-modals/shp-slot-picker@1440-staging.png
Look above the card, then at the first slot of the earliest open day. The baseline always renders a Book now chip above the card (SlotPicker.tsx:185-209; disabled with an explanatory tooltip when nothing is bookable now) and marks the first slot of the earliest open day with an Earliest badge (:299-312). Callout: neither renders inside the New-booking modal. Verified twice — today with one open slot, and 7 Aug with 31 open slots. The same picker inside the Reschedule modal renders both correctly (Book now · 4:30 PM plus EARLIEST on the first slot), so the divergence is confined to the create path (F-SHP-UI-04, S3). A second defect sits on the same control: with the date stepped to Fri, Aug 07, 2026 while the portal clock reads 6 Aug, the caption under the date stepper still reads today (F-SHP-UI-05, S3) — captured in shp-slot-picker-nobooknow@1440-staging.png, listed below.

Riyal glyph — literal markup escaping its container on Settings ▸ Notifications

Staging @ 1440 — no mockup counterpart claimed Staging Settings Notifications tab where the SMS and WhatsApp price chips render literal span markup as visible text, overrunning the chip and overprinting the neighbouring WhatsApp button
02_EVIDENCE/shop/misc/shp-settings-notifications-riyal@1440-staging.png
Look at the SMS and WhatsApp price chips in the paid-channel column. Money values are expected to render the riyal glyph inline with the amount. Here the markup is double-escaped and printed as literal text: the chip reads <span class="riyal">⃁</span> 0.50. This is a portal-side rendering observation shown alone — no mockup-side comparison is claimed for it. Callout: eight occurrences on this one tab, and the literal string is wider than its chip and escapes the container — it overprints the neighbouring WhatsApp button and runs past the table's right edge, so the paid-channel controls are partly illegible. One further occurrence sits on the Home dashboard's trial banner: then <span class="riyal">⃁</span> 225.00/mo. F-SHP-UI-01, S3 — the product owner's known issue, which reproduces on both named surfaces and is worse on this one than reported.

Image picker (H4) — the adjust step and the customer-app preview

Mockup — Adjust image modal Mockup Adjust image modal with the picture on a drag-to-reposition canvas, a circular app-crop overlay, a Zoom slider, Reset, and Cancel and Apply buttons
02_EVIDENCE/shop/hypotheses/h4-mockup-adjust-image-cropper.png
Staging — after selecting the same file Staging specialist form after picking an image: a thumbnail in the drop zone with a remove overlay, and no adjust, crop, zoom or apply controls
02_EVIDENCE/shop/hypotheses/h4-portal-avatar-picked-no-adjust.png
Both sides show the state immediately after the same 400×400 PNG was selected. Selecting a file in the baseline opens a dedicated Adjust image modal: the image on a drag-to-reposition canvas, a circular app-crop overlay matching the shape the customer app uses, a Zoom slider, Reset, and Cancel / Apply. The portal's trntv/filekit widget shows a thumbnail with an ✕ overlay. Callout: no crop, zoom, reposition, reset or apply gate, and no "in the customer app" shape preview. The file is committed to temp storage on selection rather than on Apply — selecting it fired POST /agents/avatar-upload → 200 immediately. Practical consequence: a shop owner cannot control the framing of a face in a circular avatar, which is the shape the customer app uses. Pure front-end — no data-model or API dependency; the stored artefact is a single image path on both sides.

Team — a table of specialists against a card grid, and a modal against a full page

Mockup @ 1440 — baseline Mockup Team screen rendering specialists as a table with Specialist, Title, Wage type, Fixed salary, Commission and Status columns
02_EVIDENCE/shop/team/shp-team-specialists@1440-mockup.png
Staging @ 1440 — implementation Staging Team screen rendering specialists as a card grid, each card carrying an avatar, an active toggle switch and a single unlabelled money chip
02_EVIDENCE/shop/team/shp-team-specialists@1440-staging.png
Compare how compensation is exposed on each side. The baseline is a table — SPECIALIST | TITLE | WAGE TYPE | FIXED SALARY | COMMISSION | STATUS | row actions. The portal is a card grid: avatar, name, role subtitle, an active toggle switch in place of the status badge, one money chip, email, mobile, a working-hours line and three actions (F-SHP-UI-21, S3). Callout: wage type, fixed salary and commission % are not separable on the card — a single unlabelled 1.00 ⃁ chip stands where the baseline splits salary from commission. Two further divergences sit behind the row action: the editor is a full page at /agents/update?id=N, not a modal, so the modal container and its reuse inside the setup wizard do not exist (F-SHP-UI-22, S3); and the editor carries neither the baseline's required bilingual Title field nor its Calendar colour palette — day-calendar columns are still tinted per specialist, but the tint is not editable from the portal (F-SHP-UI-23, S3). The compensation model itself matches: wage type, pay cycle, fixed salary, commission % and commission basis all carry the baseline's option set.

RTL — mirroring is correct; time strings are not localised

Mockup @ 1440 RTL — baseline Mockup day calendar in Arabic with the time gutter on the right rendering times in Arabic-Indic digits with the Arabic meridiem
02_EVIDENCE/shop/rtl/shp-bookings-day@1440rtl-mockup.png
Staging @ 1440 RTL — implementation Staging day calendar in Arabic, correctly mirrored, but with the time gutter rendering Latin digits and an English meridiem reordered so the meridiem leads
02_EVIDENCE/shop/rtl/shp-bookings-day@1440rtl-staging.png
Look first at the layout, then at the time gutter. The layout is correct. Direction flips cleanly on every sampled surface: the sidebar moves right, the calendar time gutter moves right, the zoom rail moves left, headings and table cells right-align, the modal 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. No layout mirroring defect was observed anywhere. This is the one RTL pair with a like-for-like mockup capture. Callout — the one real RTL defect. The baseline renders times as ٩:٠٠ ص — Arabic-Indic digits with the Arabic meridiem — and the date as الثلاثاء، ١٩ مايو ٢٠٢٦. The portal's 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. F-SHP-UI-27, S3 — one defect with three surfaces, not a mirroring failure.

Two further portal-side observations

Staging @ 1440 — Service Bundles Staging Service Bundles screen showing an empty card whose only affordance is a Create Service Bundle button
02_EVIDENCE/shop/services-packages/shp-packages@1440-staging.png
Staging @ 1440 — Finance earnings drawer Staging Finance earnings drawer whose header renders underneath the fixed top bar, with the booking id overprinted by the language toggle and notification badges
02_EVIDENCE/shop/finance/shp-finance-earnings-drawer@1440-staging.png
Left: the only S2 in the UI set. Right: a drawer that starts above the chrome. Left (F-SHP-UI-17, S2). The baseline mounts #/shop/packages as 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. The portal's corresponding nav slot is labelled Service Bundles, routes to /package, and renders a catalogue surface whose only affordance is Create Service Bundle. No owned-package view, sessions-remaining column or redemption-visits drawer was reachable from anywhere in the portal. It is the UI face of PKG-09. Right (F-SHP-UI-25, S3). The drawer panel starts at viewport y=0, so its header — booking id and shop name — renders underneath the fixed top bar; at 1440 the id is overprinted by the language toggle and the notification badges, and the What's new / How to controls sit on top of the drawer surface. Downgrade condition on the left-hand finding, which must travel with it: F-SHP-UI-17 is graded S2 on the strength of this 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).

Tier gating — the positive result

Staging @ 1440 — plan-gate interstitial Staging plan-gate interstitial shown instead of the Branches screen, reading This feature is not included in your current plan, Current plan Growth, with a View plans action
02_EVIDENCE/shop/hypotheses/h5-portal-tier-gate-enforced.png
H5 changed during the audit. This is what a Growth shop now sees at /branch. multi_branch sits in PRO_DELTA; Beauty Center is on Growth. The route returns 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. Callout: at the admin phase, 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 so a controller overriding behaviors() cannot bypass it, and tier gating is verified working live. One copy note recorded against the baseline: the interstitial names neither the feature nor the minimum tier the baseline quotes.

Not embedded — open these from disk

All paths are relative to parity-audit/02_EVIDENCE/shop/. Sixty further captures, plus the test-data record:

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 — no exception found

14 of 14 sampled values byte-identical between the two codebases, read with getComputedStyle on the equivalent element in each: page heading colour rgb(31, 22, 41) at 24px / 800, body-muted rgb(111, 118, 130) at 14px / 400, primary button fill rgb(89, 66, 121), app background rgb(244, 247, 247), and the identical font-stack string down to the saudi_riyal entry.

The full status-badge ramp matches on both foreground and background hex — Scheduled #5C329D on #EAE3F5, Completed #4C9686 on #E7F5F1, Collect due #C54B10 on #FCE8DD, Collected #2A9F7D on #E2F6F0, Settled #066FAA on #DCEEF7, Pending #576278 on #E9ECEF, and the Group pill #46345F on #E4DAF4 — all at 12px / 600 with pill radius. The admin pass found one token-level divergence (the CTA corner radius); the shop pass found none — the shop CTA resolves to 9999px, equivalent to the mockup's rounded-full.

Stated precisely: two badge tones present in the mockup were not reachable in staging data (Cancelled, In Progress) and two portal tones have no mockup counterpart sampled (No balance, No Show). Those four are not compared, not matched.

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. Prices render with Arabic-Indic digits on the Services cards. No layout mirroring defect was observed anywhere. The single real defect is time-string localisation (F-SHP-UI-27, above).

The overnight business-day 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 — two portal write paths that do not consult it are recorded separately at S2.

Twenty-five contracts match the baseline exactly, including load-bearing finance formulas

The passing set includes 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 (044, 046, 048), five group invariants, the collection-status derivation (070), the tip model (072 — card-only, treated as a liability rather than shop revenue), the scheduling window and platform slot lock (076), and both notification arithmetic contracts (079, 080).

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, the 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.

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, and the portal behaves the same way. F-FIN-12 was refuted because the finding misread the mockup's VAT-inclusive discount base; on the finding's own example the mockup yields 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.

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.

Limitations

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