# Parity Table — Logic / data model + selectors

Canonical = React demo (`/private/tmp/Navagoo_MI_dev/navagoo-app/src/`).
Ours = Yii2 (repo cwd). Refs are `file:line`.

| # | Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|---|
| 1 | Single normalized store, 24 entity arrays | types.ts:589 | many AR models under common/models | partial | We are relational; conceptual map only |
| 2 | Booking entity + lines | types.ts:309 | common/models/base/Booking.php:65 | done | lines = BookingService rows |
| 3 | VAT inclusive, strip via /(1+pct) | finance.ts:31 | common/models/ShopService.php:56 | done | identical formula |
| 4 | VAT skipped when shop not registered | finance.ts:32 | ShopService.php:35 + Shop.is_taxable | done | flag = is_taxable |
| 5 | Global rates 15/5/3.5/1 | types.ts:520 | backend\models\Settings (row 1) | partial | values not verified equal |
| 6 | Catalogue discount percent/fixed | finance.ts:43 | ShopService.php:16 | partial | str enum vs int 1/2 |
| 7 | Refund zones full/partial/none by hours | finance.ts:318 | common/models/Booking.php:45 | partial | demo=2 thresholds, ours=start/end band |
| 8 | No-payment → full refund freedom | — | Booking.php:56 | done | ours adds explicit guard |
| 9 | Past appointment → no refund | finance.ts:318 | Booking.php:67 | done | invert guard |
| 10 | 5 canonical booking statuses | types.ts:298 | base/Booking.php:73 | partial | we have 9 numeric; 9→5 map needed |
| 11 | Status transition machine (admin-editable) | status.ts:51 | — | missing | no enforced FSM on our side |
| 12 | allowedNextStatuses single source | status.ts:64 | — | missing | actions decided ad-hoc per controller |
| 13 | cancelledBy + refundZone on cancel | types.ts:341 | CANCELED vs CANCELED_BY_SHOP + CancellationsAndRefunds | partial | encoded as 2 statuses |
| 14 | No-show grace minutes gate | status.ts:93 | base/Shop.php:169 (no_show_threshold_minutes) | partial | field exists; enforcement unverified |
| 15 | Reschedule gated by status, not a status change | status.ts:78 | Shop.reschedule_limit + Booking.reschedule_count | partial | ours = count cap, different semantics |
| 16 | paymentTiming online/deposit/on_visit | types.ts:295 | base/Booking.php:91 | done | values match exactly |
| 17 | Shop accept-method toggles | types.ts:43 | common/models/ShopPaymentSettings.php | done | pay_*_enabled + ≥1 rule |
| 18 | Separate walk-in method gating | types.ts:51 | — | missing | no walkin*-specific toggles |
| 19 | Deposit % per-shop, capped (override→global) | types.ts:47 | ShopPaymentSettings.php (effective cap) | done | max_deposit_percent_override |
| 20 | amountCollected = rail only; in-store off-rail | types.ts:319 | Payment.paid_amount only | missing | no inStoreCollected/method field |
| 21 | Specialist card tip = off-rail liability | types.ts:329 | tipping_* + Earnings.tip_amount | partial | tip flows on-rail in ours |
| 22 | Single charges ledger (typed, rate-stamped) | types.ts:393 | base/Earnings.php:55 (denormalized cols) | partial | no append-only ledger |
| 23 | Charge reversal rows (reversalOfId) | finance.ts:205 | CancellationsAndRefunds + REFUND_DEDUCTION | partial | different shape |
| 24 | Invoice entity | types.ts:419 | — | missing | no invoice model |
| 25 | TransferRequest (settlement) entity | types.ts:446 | base/Withdrawal.php:19 | partial | Withdrawal ~ transfer |
| 26 | Settlement eligibility (terminal+hold+unsettled) | finance.ts:472 | Earnings.settlement_status + Shop.minimum_elapsed_period_days | partial | per-row status vs derived |
| 27 | settlementStateFor badge pending/in_tr/settled | selectors.ts:461 | Withdrawal.status + Earnings.settlement_status | partial | derivable, not a shared selector |
| 28 | Min withdrawal amount (5000) | selectors.ts:367 | Shop.minimum_withdrawal_amount | done | field exists |
| 29 | Customer classification navagoo_sourced/shop_owned | selectors.ts:205, types.ts:276 | — (only CustomerFreeze) | missing | drives marketing-fee scope |
| 30 | Freeze list during grace window | finance.ts:293, types.ts:550 | common/models/CustomerFreeze.php | partial | grace window not a field |
| 31 | Catalogue isActive/isShownInApp (active && !hidden) | selectors.ts:106 | ShopService STATUS_ACTIVE/ARCHIVED | partial | no separate hidden flag |
| 32 | catalogueVisible default filter | selectors.ts:112 | ShopService::find auto-scopes active | done | ShopService.php:77 |
| 33 | Service↔specialist linking (empty=anyone) | selectors.ts:116 | common/models/UserShopService.php | partial | "empty=anyone" unverified |
| 34 | Freebies (routine/addon) entity + duration add | types.ts:198, selectors.ts:99 | — | missing | no freebie model |
| 35 | ServiceBundle entity | types.ts:229 | — | missing | no bundle model |
| 36 | SubscriptionPackage (sessions/validity) | types.ts:245 | common/models/Package.php | partial | no sessions/validity/autoRenew fields |
| 37 | Group bookings (groupBookingId party) | types.ts:356, selectors.ts:266 | — | missing | no group field/derivation |
| 38 | TimeOff (shop + specialist scope) | types.ts:370 | common/models/AgentTimeOff.php | partial | specialist scope only, no shop-closed scope |
| 39 | Status color/label single source | status.ts:10 | base/Booking.php:339,385 | partial | exists but not used as FSM |
| 40 | shopKpis aggregate selector | selectors.ts:324 | — (ad-hoc queries) | partial | no single KPI selector |
| 41 | adminKpis aggregate selector | selectors.ts:428 | — (ad-hoc) | partial | GMV/earnings computed elsewhere |
| 42 | In-store cash vs card split KPI | selectors.ts:350 | — | missing | not modeled |
| 43 | Shop scoping on every entity (shopId) | selectors.ts:44 | shop_id on all models + Query scopes | done | multi-tenant parity |
| 44 | Branches via parentShopId | types.ts:63 | Shop.parent_shop_id + inherit_parent | done | |
| 45 | Subscription / SubscriptionPlan billing | types.ts:472 | — (no subscription billing model) | missing | shop subscription not modeled like demo |
| 46 | Deals/promos with usage cap | types.ts:497 | common/models/PromoCode.php / Invites | partial | usageCap/usageCount mapping unverified |
| 47 | GlobalConfig central commercial config | types.ts:520 | backend\models\Settings | partial | scattered; bookingTransitions/grace not present |

## Summary

Strong parity on the **per-entity primitives**: bookings, shop scoping, VAT math,
payment timings/methods, deposit caps, min-withdrawal, branches. The demo's
**derivation layer is far richer and more centralized** than ours:

- The demo's biggest absent concepts on our side: a **single typed charges ledger**,
  **Invoice** + **TransferRequest** as first-class entities, an **admin-editable
  booking transition FSM**, **customer classification** (navagoo_sourced/shop_owned,
  which gates marketing fees), **off-rail in-store cash/card** modeling, **group
  bookings**, **freebies/bundles**, and **shared KPI selectors**.
- Where we do match the math (VAT, deposit cap, refund intent) the *mechanism*
  differs: demo derives from a ledger + pure selectors; we persist denormalized
  fee/earning columns and decide transitions ad-hoc per controller — higher drift risk.

## New in dev demo (absent on our side)

- Single append-only `charges` ledger (typed rows + reversals).
- `Invoice` + `TransferRequest` settlement entities.
- Admin-editable `bookingTransitions` FSM + `allowedNextStatuses`/`canRescheduleStatus`/`canMarkNoShow` helpers (`lib/status.ts`).
- `CustomerClassification` (navagoo_sourced vs shop_owned + 4 reasons) — drives marketing-fee scope.
- Off-rail in-store collection (`inStoreCollected`/`inStoreMethod`) and specialist card-tip liability (`specialistTip`/`specialistTipSettled`).
- Group bookings (`groupBookingId` parties + `groupsForShop`).
- Freebies (routine/addon) + ServiceBundle catalogue types.
- Shared aggregate selectors `shopKpis` / `adminKpis` (incl. in-store cash/card split).
- Reschedule gated by *status* (not just a count cap).
- Shop-scope time-off ('shop' closed-day) in addition to specialist time-off.

Area score: 47%

## Verified verdict (adversarial)

Method: opened both demo ref and our ref for every `done`/high claim and tried to
refute it. Most `done` claims survived; one is downgraded; several `done`/`partial`
refs were wrong and are corrected (behavior still matches, evidence fixed).

### Confirmed `done` (behavior truly matches)

| # | Feature | Evidence |
|---|---|---|
| 2 | Booking + service lines | `common/models/base/Booking.php` + `common/models/BookingService.php` (both exist; `bookingServices` relation) — confirmed |
| 3 | VAT inclusive, strip via `/(1+pct)` | Formula confirmed equivalent. `ShopService::calculateVat()` (`ShopService.php:56`): `subtotal = netTotal/(1+vatRate)`; settlement path `Earnings::calculateShopVat()` (`Earnings.php:566-571`): `netAmount = finalAmount/(1+vatRate)` — matches demo `noVat` (`finance.ts:31`) |
| 4 | VAT skipped when shop not registered | **Ref corrected** → `Earnings::calculateShopVat()` `Earnings.php:551-558` gates on `shop.is_taxable == IS_TAXABLE_YES`, returns `0.00` otherwise. (NOT in `ShopService::getVat/calculateVat`, which ignore `is_taxable` — see overclaim note.) |
| 16 | paymentTiming online/deposit/on_visit | `Booking.php:91-93` constants `'online'/'deposit'/'on_visit'` == demo `types.ts:295` exact string match |
| 17 | Shop accept-method toggles (≥1) | `ShopPaymentSettings.php` `validateAtLeastOneEnabled` + `pay_*_enabled` — confirmed |
| 19 | Deposit % cap (override→global, max 100) | `ShopPaymentSettings::getEffectiveMaxDeposit()`; `shop.max_deposit_percent_override` + `settings.max_deposit_percent` columns exist (migration `m260608_120200`); functional tests `PaymentSettingsTest.php:142-146` assert ≤100 — confirmed |
| 28 | Min withdrawal (5000) | `Shop.php:218` default 5000 — confirmed |
| 43 | Shop scoping (shop_id) | 20 base models carry `shop_id`; demo also scopes by explicit `.filter(x.shopId===shopId)` (`selectors.ts:44`). Parity is **explicit-filter**, NOT an enforced ORM global scope on either side — confirmed as conceptual parity |
| 44 | Branches via parentShopId | `shop.parent_shop_id` + `inherit_parent` (`Shop.php:33-34`) == `types.ts:63` — confirmed |

### Overclaims downgraded / refs corrected

| # | Feature | Was | Now | Evidence |
|---|---|---|---|---|
| 32 | Default catalogue-visible filter | done | **partial** | Demo `catalogueVisible(list, showInactive)` (`selectors.ts:112`) is a *parameterized* selector toggled by `showInactive`. Ours hardcodes `andWhere(['status'=>STATUS_ACTIVE])` in `ShopService::find()` (`ShopService.php:77-83`) with no `showInactive` param — escape hatch is a *separate* method `findAdmin()`. Always-on scope, not the demo's toggle; also conflates "hidden" with "archived" (see #31). Default-visible behavior matches but mechanism/parameterization does not. |
| 3/4 | VAT registration skip — ref | `ShopService.php:35/:56` | ref fixed → `Earnings.php:551-558` | Analyst pointed the registration-skip at `ShopService::getVat()`/`calculateVat()`, but **those methods never read `is_taxable`** — they unconditionally apply the global `settings.taxes` rate (`ShopService.php:35-66`). The real registration gate is only in the settlement path `Earnings::calculateShopVat()`. Behavior still matches the demo (kept `done`), but the catalogue-display VAT (`ShopService`) is NOT registration-aware — a latent drift the analyst's evidence masked. |

### Other checks (analyst's `partial`/`missing` confirmed accurate)

- #5 global rates 15/5/3.5/1 — **partial confirmed**: `Settings` has `taxes`, `service_fees`,
  `platform_commission` only; no discrete marketing-fee % / processing-fee (pct+fixed) fields
  matching demo `GlobalConfig` (`types.ts:520-533`). Four-rate structure not all present.
- #7 refund zones — **partial confirmed**: ours `refund_period_start`/`_end` band
  (`common/models/Booking.php:73-85`) is thresholds equivalent to demo
  `cancelFullHours`/`cancelPartialHours` (`finance.ts:317-322`), but ours lacks the
  `partialRefundPct`-based money split and the `cancelledBy==shop ⇒ full` / deposit-non-refundable
  branches of `customerRefund` (`finance.ts:326-339`).
- #11/#12 FSM / allowedNextStatuses — **missing confirmed**: `lib/status.ts:51-71`
  `DEFAULT_TRANSITIONS` + `allowedNextStatuses` have no analogue; no enforced state machine.
- #18 walk-in method gating — **missing confirmed**: demo `walkinOnline/walkinDeposit/walkinOnVisit`
  (`types.ts:51-55`); `ShopPaymentSettings` has no walk-in-specific toggles.
- #29 customer classification — **missing confirmed**: demo `classifyCustomer` (`finance.ts:303-313`)
  with 4 reasons drives marketing-fee scope; we only have `CustomerFreeze` (freeze, not classification).
- #31 catalogue active && !hidden — **partial confirmed**: demo has TWO flags `active` + `hidden`
  (`selectors.ts:106-108`); ours has single `STATUS_ACTIVE/ARCHIVED` only.

### Adjusted area score

Verification largely upholds the analyst (the assessment was already conservative —
mostly `partial`/`missing`). Net change: one `done`→`partial` downgrade (#32) and two
evidence/ref corrections (#3/#4, with a flagged latent drift in catalogue-display VAT).

**Adjusted area score: 46%** (was 47%).

