Parity audit · Evidence board

Navagoo admin portal — mockup vs. staging

A contract-by-contract comparison of the admin 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 counts, the seven S1 findings, the resolution of the five pre-identified concerns, and the visual evidence. The narrative write-up is in REPORT_ADMIN.md.

Baseline (mockup)
cb48c8d — v0.28.0
React demo, #/admin/*, audited as super_admin
Implementation
Staging admin portal
stageadmin.navagoo.com, role admin
Local portal ref
5efbafc9
branch parity-audit off tailwind-poc
Observation dates
2026-08-05 → 2026-08-06
Coverage
83 of 83 contracts evaluated
101 surfaces inventoried
Still open
30 contracts need a live probe

Scorecard

87 confirmed findings across eight areas. Three 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 or access outcome 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.

7
S1
Reachable path, wrong money or access
38
S2
Wrong figure shown, or a flow that dead-ends
33
S3
Bounded behavioural deviation
9
S4
Presentational / label divergence
Area S1 S2 S3 S4 Confirmed Refuted Probes needed
subscriptions-entitlements28611715
finance-ledger15221004
finance-invoices-transfers16721604
commercial-config2340903
catalogue-geography05411005
system-rbac05521203
notifications1320623
adjacent-admin0331703
Total73833987330

Counts reflect the re-verification pass: two finance-invoices-transfers findings moved from S1 to S2 (F-FIT-02, F-FIT-03) and two were merged into one (F-FIT-01 + F-FIT-05), which is why the confirmed total is 87 rather than 88.

The seven S1 findings

Each of these was re-checked by an independent hostile pass that required four things before the rating could stand: quoted code from the cited files, evidence that the path is reachable in production, a check against the mockup baseline, and a worked failure scenario with numbers. Seven were upheld; two were downgraded and two merged.

S1 SE-02

Renewal charges the gross plan price, ignoring an enrolled offer's discount

C-ADM-067deviationbuilt, not working
Baseline

At renewal the charge is raised on the net price — the enrolled offer's sub-discount applies while now <= lockedUntil — and currentTermPrice is re-stamped to that net.

Observed

SubscriptionBillingController::actionCharge() line 203 sets $amount = $plan->priceForPeriod($period). No ShopOfferEnrollment lookup occurs anywhere in the console tier, and current_term_price is not consulted. The subscribe path does apply netFor(), so the discount holds on charge one and is lost from charge two onward.

Failure scenario

20%-off offer, 12-month lock, Growth plan at 500 SAR/month. Subscribe charges 400 and stamps current_term_price = 400. The next cron run charges 500 and re-stamps the term price to 500 — 100 SAR/month over for the 11 remaining locked months. The inflated stamp also shrinks any later upgrade proration, which reads current_term_price.

Reachability: the subscription-billing/run cron is scheduled daily on both qc and prod (console/config/schedule.php:42,53), so this is a live path. Live corroboration: CHG-0006 on staging charges 1,696.50 = 1,885.00 × 0.90, i.e. the subscribe path applying a 10% offer correctly — that shop is a standing reproduction case for the renewal divergence.
Files
  • console/controllers/SubscriptionBillingController.php
  • frontend/controllers/NavagooPlansController.php
  • common/models/NavagooOffer.php
S1 SE-03

A card-rail subscription with no card on file renews as paid

C-ADM-068deviationbuilt, not working
Baseline

A term counts as paid only when paymentMethod === 'card' and a cardLast4 is on file. Otherwise the charge is unpaid and the subscription moves to past_due with pastDueSince set.

Observed

chargeSubscription() (:467-492) computes $useRealRail = $card && PaymobSubscriptionHelper::isConfigured() && token. When that is false — including when the shop has no saved card at all — control falls through to writeSubscriptionCharge() with Charge::STATUS_PAID. actionCharge() then stamps a new term, sets status active, clears past_due_since and emails a renewal confirmation.

Failure scenario

Growth plan at 500 SAR, no card on file. Each cycle writes type = subscription, total_amount = 500, status = paid, settlement_method = charge_to_card with no meta.transaction_id, activates the subscription and clears past-due. Zero is collected; ledger revenue is overstated by 500 per shop per term, and the subscription renews indefinitely.

Not environment-gated: the fallback carries no dev/prod flag. With Paymob fully configured, a shop with no user_card row still falls through, because $useRealRail requires $card to be non-null. Nothing upstream requires a card either — actionSubscribe never rejects a card-rail subscribe from a user without one.
Files
  • console/controllers/SubscriptionBillingController.php
S1 FIN-LEDGER-01

No completion path raises a marketing fee into the charge ledger

C-ADM-013gapbuilt, not configured
Baseline

A navagoo-sourced booking raises a marketing fee when it reaches a terminal state; a completed pay-on-visit booking raises it even though nothing was collected on the rail. The mockup re-derives charges on every status transition (store.ts:1112).

Observed

The marketing-fee builder matches the baseline formula, but no completion or no-show transition invokes it. BookingCompletionService::ensureEarnings() — the shared chokepoint for solo completion, group-child completion and collect-then-complete — calls ensureProcessingFee() only. deriveBookingCharges() has exactly six call sites; none is a completion path.

Failure scenario

500.00 booking, marketing rate 5%, VAT 15%. Expected charge row: basis 434.78, fee 21.74, VAT 3.26, total 25.00. Actual: no row is written. netPayout shows 483.04 instead of 458.04, and costsToDate() is understated by 25.00 per booking.

Corroborated live, with a scoping caveat. The staging ledger holds six charge rows and no marketing_fee row at all; two completed bookings on 3 Aug (91.00 and 136.00) each produced a processing-fee row and no marketing-fee row, and all eight transfer requests show Marketing 0.00. Remediation must target the charge ledger, not the payout rail. A parallel legacy rail (navagoo_marketing_fees, written by Earnings::calculateFinancialFields()) is what the payout actually nets, so real cash payout may still deduct a marketing fee. What is established as wrong here are the charge-ledger-driven figures shown to shops and admins.
Files
  • common/services/BookingCompletionService.php
  • common/components/FinanceLedgerService.php
S1 F-FIT-01+05

No bidirectional link between the settlement rail and the fee rail

C-ADM-024deviationbuilt, not workingmerged
Baseline

On creation, a transfer request writes transferRequestId onto every included charge row so it cannot be re-batched, and cost/invoice builders exclude anything carrying a transfer id. Eligible bookings are those not already consumed by a prior transfer and not already netted into an issued invoice's appliedBookingIds.

Observed

Transfer → invoice: linkEarnings() (:374-387) tags only SMS/WhatsApp charge rows; marketing-fee and processing-fee rows are never tagged. Invoice → transfer: eligibleEarnings() filters on settlement_status and withdrawal_id IS NULL only. Issuing an invoice records the netted bookings in invoice.applied_booking_ids but sets nothing on shop_earning.

Failure scenario

An admin opens Shop Balances, which auto-runs reconcileBilling(). A threshold invoice nets 1,000 SAR of held earnings against 98.90 of fees, marks the invoice and its charges paid, and records applied_booking_ids. The shop then clicks Request transfer; the shop_earning row is untouched, so it is bundled and paid out in full. The 98.90 is recorded as recovered from money that is simultaneously paid out.

Correction carried from re-verification: the original F-FIT-01 causal chain was partly wrong. A string coerced to 0 is not NULL, so notInTransfer() and consumedBookingIds() behave correctly for bookings that carry a notification charge. The defect bites bookings with no notification charge — which is the common case. The int/string column mismatch is tracked separately as OQ-FIT-A.
Files
  • common/components/WithdrawalBundlingService.php
  • common/models/query/ChargeQuery.php
  • common/migrations/db/m260623_211000_create_charge_ledger.php
  • common/components/FinanceLedgerService.php
  • backend/controllers/AdminFinanceController.php
S1 CF-CC-01

The admin-edited carry-forward threshold has no reader; every shop resolves to a hardcoded 200.00

C-ADM-034deviationbuilt, not working
Baseline

effectiveCarryThreshold(shop, config) = shop.carryForwardThreshold ?? config.carryForwardThreshold. The global threshold the admin edits is the live fallback for every shop without a per-shop override, and it drives threshold auto-invoicing. Global seed: 1000.

Observed

AdminFinanceController::effectiveCarryThreshold() (:326-336) checks the per-shop column, then backend\models\Settings->carry_forward_threshold, then a hardcoded 200.0. The settings table has no such column and no migration adds one, so the middle branch is dead against the schema. The value edited on Commercial Config — commercial_config.carry_forward_threshold — has no reader anywhere in the codebase.

Failure scenario

Admin sets the threshold to 1000. A shop has a NULL per-shop override, 300 SAR of issuable fees and 300 SAR of held earnings. Baseline: 300 < 1000, the balance carries forward and the earnings stay withdrawable. Portal: 300 ≥ 200, so an invoice is issued and 300 of held earnings are consumed and removed from withdrawable. The screen also shows a credit limit of 200.00 for every shop.

Framing correction: the wrong numbers are the credit limit and the timing of invoicing. The invoice amount itself is computed correctly — this must not be read as a mis-stated invoice total.
Files
  • backend/controllers/AdminFinanceController.php
  • backend/models/Settings.php
  • common/models/CommercialConfig.php
  • backend/views/commercial-config/index.php
S1 CF-CC-02

Offer fee-discounts are computed and tested but never applied to a charge

C-ADM-036gapbuilt, not configureddormant on seed data
Baseline

An offer's rateDiscountPct reduces the stamped rate on the charge types it lists (marketing_fee / payment_processing_fee) for any charge whose economic date falls inside [enrolledAt, lockedUntil].

Observed

OfferPricingService::rateDiscountPct() (:95-108) is correct and unit-tested, but has no caller — a repo-wide search returns only its own definition, its test, and a comment. A case-insensitive grep for offer|discount across FinanceLedgerService.php returns zero hits; buildMarketingFee() and buildProcessingFee() stamp the undiscounted rate.

Failure scenario

Basis 1000 SAR, marketing rate 10%, offer 25% on marketing_fee. Baseline stamps an effective 7.5% → 75.00. Portal stamps 10% → 100.00. A 25.00 overcharge per booking for the full lock-in duration.

Dormancy caveat: both seeded offers carry rate_discount_pct = 0, so a staging probe run today shows no discrepancy. The defect activates the moment an admin creates a fee-discount offer — an ordinary admin action. The shop-facing UI already promises the behaviour (frontend/views/navagoo-plans/index.php:382, "{pct}% off fees").
Files
  • common/components/FinanceLedgerService.php
  • common/components/OfferPricingService.php
  • common/models/NavagooOffer.php
  • common/models/ShopOfferEnrollment.php
S1 NOTIF-02

The free-message allowance resolver takes no plan, so plan-bundled allowances are not honoured

C-ADM-060deviationbuilt, not workingdormant on seed data
Baseline

notifFreeLimit(config, plan, channel) = plan.smsIncluded ?? config.smsFreeMonthly — the subscribed plan's bundled allowance wins and the global default is only the fallback. The same resolver serves both send engines and the shop-facing pre-send estimate.

Observed

freeLimitFor(string $channel) (:467-472) takes no shop or plan argument. It resolves CommercialConfig.sms_free_month / wa_free_month, falling back to the 50 SMS / 20 WhatsApp constants. navagoo_subscription_plan.sms_included and wa_included exist and are admin-editable, but nothing reads them at billing time. The shop-facing figure is separately re-derived in two more places, so the contract's single-gate property does not hold.

Failure scenario

Pro shop with sms_included = 500, sms_sell_price = 0.25, 200 SMS sent in a month. Baseline: 0 charges. Portal: 150 charge rows × 0.25 = 37.50 plus VAT, billed inside an allowance the shop already bought. The Settings tile also shows "50 free" to a shop on a 500-SMS plan.

Dormancy caveat: commercial_config.sms_sell_price defaults to 0.0000 NOT NULL and configValue() treats 0 as a value, so overage rows are written at 0.00 SAR until an admin sets a sell price. The wrong allowance is stamped regardless of the price, so the defect is already present in the data.
Files
  • common/components/NotificationDispatchService.php
  • common/models/NavagooSubscriptionPlan.php
  • frontend/controllers/SettingsController.php
  • frontend/views/settings/_notifications.php

The five pre-identified concerns

Five concerns were raised before the audit began and verified first, because their answers shape everything downstream. Two resolved differently from how they were originally framed, and one was refuted outright. Every layout claim below is a measured DOM number, not a visual impression.

Concern Resolution What the measurement showed
H1
Cards and text ignore container margins at some widths; components overlap
PARTLY CONFIRMED
overlap clause disconfirmed
One genuine defect across 32 screen × width probes: on /shop/update, an unbreakable 34-character IBAN escapes its tile at 1024 (133 px spill) and 1280 (48 px), clean at 1440 and 1920 (F-ADM-H1-01, S3). The overlap claim was disconfirmed by measurement — zero intersecting pairs of cards, panels or text blocks on any screen at any width. Every intersection the probe reported was <path>/<rect> geometry inside a single SVG icon (9–138 px²).
H2
Shop edit is an iframe-hosted legacy form inside a modal
CONFIRMED WITH A DIFFERENT MECHANISM
disconfirmed on presentation, confirmed on density
The shop edit is a full page in the main document — not an iframe, not a modal. The page's one <iframe> is the Google Maps resize-detection frame (empty, opacity: 0, z-index: -1); the two .modal-panel nodes measure 0 × 0 at opacity 0 and are dormant layout shells. Density does diverge: 45 visible controls vs 15 in the baseline modal (3.0×), in 12 sections vs 3.
H5
Plan entitlements are not enforced; a subscribed shop receives all features regardless of tier
CONFIRMED WITH A DIFFERENT MECHANISM Enforcement is wired, not absent: EntitlementFilter is an ActionFilter attached globally and renders a locked view on gated actions. Access is unrestricted for a different reason — shopCanAccess() takes $defaultAllow = true and returns it when a shop has no subscription row, a documented live-rollout posture. Compounding it: the admin feature matrix is 0 of 57 cells populated, and plans stamp display labels rather than canonical feature keys, so the engine falls back to tier defaults. The matrix does not currently influence entitlement at all.
H7
SMS and WhatsApp notification triggers do not dispatch to their providers
CONFIRMED The metering and billing pipeline is built — allowance consumption, append-only Charge rows, refund-on-failure. The provider gateway on the trigger path is not: the service's own comment block records that the Msegat/Twilio send "is still a separate integration — no gateway is wired yet". Provider-calling code exists elsewhere (SMSHelper, WhatsAppHelper); it is the trigger dispatch path specifically that does not reach one.
H8
The admin subscription-plans management screen is not operative
REFUTED The screen is present and functional: six tabs, a New plan action, three plan cards with per-period pricing, specialist bands and trial terms, and a feature matrix with save-on-change. A separate observation on the same screen stands on its own: document.querySelectorAll('table input[type=checkbox]') returns 57 total, 0 checked — all 19 feature rows read - - - across Starter/Growth/Pro.

Visual evidence

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

Mockup-side captures exist for the shop-edit surface only. H1 was specified as a portal-side layout probe, so matching-width mockup screenshots were not captured for the eight layout screens. Where no mockup counterpart exists, the staging capture is shown alone and labelled as such — no mockup-side layout comparison is claimed for those surfaces.

Shop edit — the IBAN overflow (F-ADM-H1-01)

Staging @ 1024 Staging admin shop-edit Shop Overview card at 1024 px, IBAN value running past the tile border to the viewport edge
02_EVIDENCE/admin/shop-edit/shop-overview-spill@1024-staging.png
Staging @ 1280 Same card at 1280 px, IBAN still overflowing its tile but remaining on screen
02_EVIDENCE/admin/shop-edit/shop-overview-spill@1280-staging.png
Look at the third tile of the Shop Overview card — IBAN NUMBER. The value is SA22000000000000000000000000000000: 34 characters, one unbroken token. Computed style on the value element is word-break: normal; overflow-wrap: normal; text-overflow: clip; overflow-x: visible, so it cannot wrap and is not ellipsised. The measured grid gives the tile a 209 px content box at 1024 and 294 px at 1280, against a 310 px text width. Callout: at 1024 the string renders through the tile border, past the card's right edge, and 49 px off the right of the viewport. Because the page has no horizontal scroll (scrollWidth === clientWidth on every screen at every width), that last segment is unreachable — the IBAN cannot be read in full at 1024. At 1280 the spill is 48 px but stays on screen.
Staging @ 1440 — clean, for contrast Same card at 1440 px with the IBAN fitting inside its tile
02_EVIDENCE/admin/shop-edit/shop-overview-spill@1440-staging.png
Staging @ 1024 — card crop Cropped Shop Overview card at 1024 px showing the three read-only tiles
02_EVIDENCE/admin/shop-edit/shop-overview-card@1024-staging.png
The same tile at 1440, where it resolves. The grid moves to 350px 350px 350px, giving a 348 px content box against a 316 px text width — spill 0. The overflow reported further up the ancestor chain (div.tw-shop-form, form#shop-form, div.ng-page, all showing scrollWidth − clientWidth of 96 px at 1024 and 11 px at 1280) resolves together with the tile, which is why it is read as a consequence of the single tile rather than as independent defects.

Shop edit — page vs. modal, and the density difference

Mockup @ 1440 — baseline Mockup admin shops list at 1440 px with the Edit Beauty Center modal open over it
02_EVIDENCE/admin/shop-edit/shop-edit@1440-mockup.png
Staging @ 1440 — implementation Staging admin shop update page at 1440 px, a full page of stacked form sections
02_EVIDENCE/admin/shop-edit/shop-edit@1440-staging.png
Left is the baseline; right is the implementation. Look at how each hosts the form. The mockup opens a centred 672 × 868 px modal over #/admin/shops — the hash does not change, and the list stays visible behind it. The portal navigates to /shop/update?id=20, its own route with its own <title>, and renders the form as ten stacked cards plus two read-only cards. Both are ordinary DOM in the main document; neither is an iframe. Notably, the portal already ships the baseline's modal component — two dormant .modal-panel shells carry the same class signature — the shop-edit surface simply does not use it.
Mockup — modal detail The mockup Edit Beauty Center modal on its own, showing three sections and fifteen controls
02_EVIDENCE/admin/shop-edit/shop-edit-modal@1440-mockup.png
Staging @ 1024 — full page Staging admin shop update page at 1024 px
02_EVIDENCE/admin/shop-edit/shop-edit@1024-staging.png
Field density: 15 controls in 3 sections vs 45 controls in 12. The baseline modal carries BASIC INFO · STORE DETAILS · SETTINGS & TRANSFER THRESHOLDS. The portal adds Media, Location Information, Shop Settings, a VAT section, Additional Information, Shop Documents and a nested "CURRENT RATE" payment-processing-fee panel — roughly 32 extra controls across 9 groups, carrying data the demo does not model (compliance documents, a scheduled rate-change workflow with an audit reason, operating hours, a map picker). One baseline field has no portal counterpart: the paired ENGLISH / العربية commercial name with per-field translate actions (visible top-left of the modal). The portal probe returned translateButtons: 0 and arabicNamedInputs: [] (F-ADM-H2-01, S3). Also visible: the portal shows commercial name, bank and IBAN twice — read-only in Shop Overview and again as editable inputs in Store Details.

Other surfaces — staging captures

These four are representative of the eight screens probed for layout. No mockup counterpart was captured at matching widths, so they are shown as single-side observations. All four were clean at page level: scrollWidth === clientWidth at every tested width.

Staging @ 1440 — shops list Staging admin shops list at 1440 px
02_EVIDENCE/admin/shops/shops-list@1440-staging.png
Staging @ 1024 — transfer requests Staging admin transfer requests table at 1024 px inside a horizontal scroll wrapper
02_EVIDENCE/admin/finance/finance-transfers@1024-staging.png
Wide tables are contained by scroll wrappers, not clipped (OBS-1 — not a defect). Both tables sit inside a div with computed overflow-x: auto, so nothing is clipped and the page never scrolls horizontally. Recorded as a density observation only: the transfer-requests table measures 1,546 px, so at 1024 7 of its 12 columns — including NET PAYOUT, AMOUNT PAID and STATUS — require horizontal scrolling; 5 of 12 at 1280, 4 of 12 at 1440, and it fits at 1920. The shops list puts 2 of 8 columns past the fold at 1024. This table is also where the live ledger probe was read: every one of the eight transfer requests shows Marketing 0.00 and Notifications 0.00, including one with 2,297.01 earned.
Staging @ 1440 — subscription plans Staging admin subscription plans screen at 1440 px
02_EVIDENCE/admin/plans/plans@1440-staging.png
Staging @ 1440 — dashboard Staging admin dashboard at 1440 px
02_EVIDENCE/admin/dashboard/dashboard@1440-staging.png
Left: the screen H8 predicted would be inoperative. It renders a six-tab section (Shops · Requests · Subscription Plans · Offers · Subscription Dashboard · Payment methods), a New plan action, and three plan cards — Starter (1–3 specialists, 99.00 / 535.00 / 950.00), Growth (4–10, 225.00 / 1,215.00 / 2,160.00) and Pro (11+, 349.00 / 1,885.00 / 3,350.00), each with a 30-day trial. The feature matrix further down the same screen is the 57-checkbox / 0-checked measurement. Both screens are clean at every probed width.
Staging @ 1440 — settings Staging admin settings screen at 1440 px
02_EVIDENCE/admin/settings/settings@1440-staging.png
Staging @ 1440 — analytics Staging admin analytics screen at 1440 px
02_EVIDENCE/admin/analytics/analytics@1440-staging.png
Two of the remaining layout screens, both clean at all four widths. The only overflow: hidden elements carrying overflowing content anywhere in the 32-probe run were span.sr-only visually-hidden utilities and decorative gradient layers (OBS-4), and the only off-viewport absolutely-positioned elements were Google Maps' 256 × 256 px internal tiles inside a clipped div#map (OBS-2). Neither is a portal layout defect.

Not embedded — open these from disk

All paths are relative to parity-audit/02_EVIDENCE/admin/. Twenty-six further captures, plus the live-probe text record:

What is working

Navigation information architecture is at full parity

The admin sidebar's sections and entries match the baseline's ADMIN_NAV structure. No nav item was found missing, misplaced or extra when audited as super_admin — the role that matters, since the baseline nav is permission-filtered and auditing under a narrower role would manufacture false "missing item" findings.

The entitlement engine is a faithful, test-pinned port

common/components/EntitlementService.php tracks the mockup's src/lib/entitlements.ts closely: the same three cumulative tiers (starter ⊂ growth ⊂ pro), the same feature keys, TIER_RANK, minTierFor, exceedsBand as an advisory rather than a hard block, and suggestedTierForCount. It is covered by common/tests/unit/components/EntitlementServiceTest.php. Enforcement is wired too — EntitlementFilter is an ActionFilter attached globally at frontend/config/web.php:94, rendering a locked view in place of a gated action.

One detail worth recording so it is not later reported as a gap: EntitlementService::hasRole() returns true for every feature, which reads like an unfinished RBAC axis. The mockup does the same — entitlements.ts:186-195 defines hasRole against a FEATURE_PERMISSION map that is empty by design. The portal matches the baseline here.

Zero page-level horizontal overflow across 32 probes

Eight screens × four widths (1024 / 1280 / 1440 / 1920): document.documentElement.scrollWidth === clientWidth in all 32 combinations, delta 0 every time. No screen produces a horizontal page scrollbar. Wide tables are contained by overflow-x: auto wrappers rather than clipped, which is the standard responsive pattern. And across all 32 combinations, zero intersecting pairs of cards, panels or text blocks were found.

The notification metering and billing pipeline is built

Allowance consumption, append-only Charge rows carrying unit sell price plus VAT and linked to the send via charge.meta.notification_id, and an admin-configured per-message refund on reported delivery failure are all present and reachable. What is missing on this path is the provider gateway, not the accounting (see H7, and NOTIF-02 for the allowance resolution defect).

Limitations