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
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-finance
3
6
3
1
13
2
21
19.0%
6
packages
3
5
0
0
8
0
9
11.1%
4
collect-complete
3
4
1
0
8
0
6
33.3%
6
booking-engine
2
5
6
1
14
1
22
31.8%
6
settings-notifications
0
3
4
1
8
1
8
37.5%
4
classification
0
2
2
1
5
0
5
60.0%
5
group-bookings
0
2
1
1
4
3
11
45.5%
4
Total
11
27
17
5
60
7
82
30.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.
S1F-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.
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.
S1F-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
S1F-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.
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.
S1PKG-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
S1PKG-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
S1PKG-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
S1CF-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.
S1CF-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.
S1BE-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
S1BE-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 zerodata-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 PAIDCharge
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
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
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
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
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
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
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
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.
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).
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:
test-data.md (text — the staging records touched during the audit)
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.
UI parity reaches 51 of 151 inventoried surfaces. No claim is made about the other 100 in
either direction. Specifically not compared on both sides: group-booking flows
end to end (the party drawer's whole-party action bar and all three party modals — a
group row exists in staging 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 (the cancelled branch with its refund
line, the no-show branch, the grace-window-disabled tooltip, the per-row actions menu, the
notification-bell contents); Finance Detailed charges and
Invoices including invoice pay/view, the payment-method picker, the
package-sale drawer and print layouts; Team Payroll and Structure and their eight
modals; the Services extras tabs; Customers Classifications and Freeze-list; the Plans
confirm/add-card/cancel modals; most Home dashboard panels; and the whole shell
layer — account-settings modal, notifications bell dropdown, the notifications screen,
terms gate, setup wizard, collapsed sidebar. 1280 px was checked only on the bookings list, at
the column-drop boundary.
The Packages S2 carries a staging caveat that must travel with it.F-SHP-UI-17 is graded S2 on the strength of the empty state's create-CTA;
staging has zero service 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 Paymob charge branch is established by inference, not by a completed charge.
H6's conclusion rests on code plus three runtime probes — the tokenization flag read from the
live DOM, the server's own refusal on card-token-start, and the saved card carrying
a locally generated placeholder token. Reaching
recordSubscriptionCharge() with a non-zero amount required ending the shop's live
free trial (trial_consumed is already 1, so a fresh subscribe would append an
irreversible PAID ledger row), and no second shop-portal login was available.
Well-constrained, but stated as an inference.
Commercial rates read zero across staging for most of this audit, so fee arithmetic
cannot be validated there as it stands. A probe raised them mid-way and left them
raised, but the ledger these findings were written against accumulated under zero rates: 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. 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. Rows already written
carry their stamped rate, so a probe must create fresh records after the rate change rather than
read old ones.
35 live probes across 31 contracts are recommended and not run. Those findings
rest on source reading plus the observations recorded here rather than on a runtime confirmation.
Each carries a written recipe and a stated confirm/refute condition in 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
(whether freebie minutes are folded into ShopService.service_period at save time)
and C-SHP-073 (the CF-CC-05 network-response half).
Other dormancy conditions that will make a naive probe read nothing.BE-F03 requires close_at <= open_at — a supported configuration,
but one no probed shop had. F-FIN-06 needs an enrolled offer with a non-zero
rate_discount_pct; none exists. CF-CC-01 / BE-F04 reach
update-status, which has no caller in any view or JS. SN-09's
seeded triggers were force-approved for in-app by migration. 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.
Loading and error states are deliberately not graded. The mockup reads a
synchronous store, 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, 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
absent Staff logins tab is recorded as not-comparable rather than as
a gap. The no-subscription entitlement branch (H5) is unverified for the same reason.
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 listed above.
The build is fingerprinted, not versioned. Neither deployment exposes a version
string. The staging Tailwind bundle was built 2026-08-03 17:41 GMT against a local HEAD
of 2026-08-05; five of the six intervening commits are api/ tier work, out of scope,
and the sixth is dated the same day as the staging build. Staging is a sound proxy for
local HEAD for this portal — but asset timestamps bound the build, they do not prove it.
Findings are reported against observed staging behaviour with local code read as corroboration,
never the reverse.
Track D (documentation currency) has not been run. It is a Phase C deliverable.