# Logic & Data Model — Parity (Logic / selectors / entities)

Canonical = React demo (dev branch) at
`/private/tmp/Navagoo_MI_dev/navagoo-app/src/`. Ours = Yii2 in repo cwd.

## 1. Data-model shape (demo `types.ts`)

The demo is a **single normalized in-memory store** (`DataState`, `types.ts:589-624`)
with ~24 top-level entity arrays. Everything is derived by pure selector functions
over that state (`store/selectors.ts`) and a pure finance engine (`lib/finance.ts`).
Field names are camelCase, enum *values* mirror the live backend.

Demo entities (`types.ts`):
`config` (GlobalConfig), `shops`, `specialists`, `categories` (ServiceCategory),
`services`, `freebies`, `bundles` (ServiceBundle), `packages` (SubscriptionPackage),
`customers`, `classifications` (CustomerClassification), `bookings`, `timeOff`,
`charges` (THE ledger), `invoices`, `transfers` (TransferRequest), `subscriptions`,
`plans`, `deals`, `cards`, `freezeList`, `cities`, `users`, `activity`.

The single most important architectural fact: the demo has **one append-only
`charges` ledger** (`types.ts:393-411`) of typed fee rows
(`marketing_fee | payment_processing_fee | subscription | sms_fee | wa_fee`), and
**settlement is derived** from un-consumed bookings + their charge rows
(`shopSettlement`, `finance.ts:493`). Invoices and TransferRequests are separate
first-class entities.

## 2. How OURS models the same thing

Ours is a relational Yii2 schema. Mapping is conceptual, not 1:1:

| Demo concept | Our model | Ref |
|---|---|---|
| `Booking` | `Booking` / `base\Booking` | `common/models/base/Booking.php:65` |
| `Booking.lines[]` | `BookingService` rows | `common/models/BookingService.php` |
| `Service` (global) | `Service` | `common/models/Service.php` |
| `Service` (shop-priced) — demo flattens both into one `Service` | `ShopService` (shop-scoped priced copy) | `common/models/ShopService.php`, base props `base/ShopService.php` |
| `Charge` ledger row | **No dedicated ledger.** Fee columns are denormalized onto each `Earnings` row (`navagoo_marketing_percentage`, `navagoo_marketing_fees`, `vat_navagoo`, `navagoo_net_fees`, `service_fee`) | `common/models/base/Earnings.php:55-66` |
| `Invoice` | — (no invoice entity) | — |
| `TransferRequest` (settlement) | `Withdrawal` (+ `AgentWithdrawal`) | `common/models/base/Withdrawal.php:19-52` |
| `Subscription`/`SubscriptionPlan` | `UserPackage`/`Package` partially; subscription billing not modeled like demo | `common/models/Package.php` |
| `Customer` | `User` (role=customer) + `UserProfile` | `common/models/User.php` |
| `CustomerClassification` | — (no classification entity; `CustomerFreeze` covers freeze only) | `common/models/CustomerFreeze.php` |
| `FreezeEntry` | `CustomerFreeze` (explicit port) | `common/models/CustomerFreeze.php:11` |
| `TimeOff` | `AgentTimeOff` (specialist scope only) | `common/models/AgentTimeOff.php` |
| `Specialist` + `Compensation` + `WorkingDay` | `User` (role=agent) + `UserShift` + `AgentSlots` | `common/models/UserShift.php`, `AgentSlots.php` |
| `Freebie` (routine/addon) | — (no model) | — |
| `ServiceBundle` | — (no bundle model) | — |
| `Deal` | `PromoCode` / `Invites` (partial) | `common/models/PromoCode.php` |
| `GlobalConfig` | `backend\models\Settings` (row id=1) | referenced in `ShopService::getVat`, `Shop::getDistanceRange` |
| Payment record (`amountCollected` rail) | `Payment` | `common/models/base/Payment.php:17-43` |

## 3. Selectors / derivations (demo `selectors.ts`) → ours

- **Lookups by id / per-shop filters** (`selectors.ts:36-56`): ours = Yii AR
  relations + `BookingQuery`/`ShopServiceQuery` scopes. ShopService even
  auto-scopes to `STATUS_ACTIVE` in `find()` (`ShopService.php:77-84`), matching the
  demo's `catalogueVisible`/`isActive` (`selectors.ts:106-114`).
- **Catalogue lifecycle** `isActive`/`isShownInApp` (`selectors.ts:104-114`): ours
  has `ShopService::STATUS_ACTIVE|STATUS_ARCHIVED` + `archive()`/`softDelete()`
  (`ShopService.php:14-133`). Partial — no separate `hidden` flag (demo distinguishes
  inactive vs hidden-but-active).
- **Service ↔ specialist linking** (`specialistsForService`, `bookableServices`,
  `specialistsForServices`, `selectors.ts:116-164`): ours = `UserShopService`
  pivot (`common/models/UserShopService.php`). The "empty link = anyone" rule is a
  demo behavior; not verified in ours.
- **Total duration incl. freebies** (`totalDuration`, `selectors.ts:99-102`): ours
  has no freebie concept; duration is `service_period`/`from_hour..to_hour`
  (`Booking::getScheduledDuration`, `Booking.php:404`).
- **Status counts** (`statusCounts`, `selectors.ts:223`): ours computes per-status
  counts ad-hoc in controllers/grids (no shared selector). Status enums differ
  (see business.md §3).
- **Shop KPIs** (`shopKpis`, `selectors.ts:324-374`): a single pure function returns
  counts, totalEarned (rail), revenue (all channels incl. in-store cash),
  netEarnings, netAvailable (withdrawable), costsToDate, meetsWithdrawalMin,
  in-grace, in-store cash/card split. Ours computes settlement/earnings from
  `Earnings` rows + `Withdrawal`; there is no single equivalent KPI selector and no
  explicit "in-store cash vs card" off-rail split in the data model.
- **Settlement eligibility** (`eligibleTransferBookings` + `isEligible`,
  `selectors.ts:167`, `finance.ts:472`): demo rule = booking is terminal, settlement
  fees done, `daysBetween(now, txnDate) >= settlementHoldDays`, not already in a
  transfer/settled. Ours = `Earnings.settlement_status` state machine
  (`PENDING→REQUESTED→APPROVED→PROCESSED`, `base/Earnings.php:108-113`) +
  `Shop.minimum_elapsed_period_days` + `Shop.minimum_withdrawal_amount`. Conceptually
  equivalent but driven by a per-earning status column rather than derived from a
  ledger.
- **Settlement-state badge** (`settlementStateFor` → pending|in_tr|settled,
  `selectors.ts:461-478`): ours = `Withdrawal.status`
  (`PENDING|READY_TO_WITHDRAWAL|ACCEPTED_BY_ADMIN`, `Withdrawal.php:49-52`) +
  `Earnings.settlement_status`.
- **Group bookings** (`groupOf`/`groupsForShop`, `selectors.ts:266-306`): demo derives
  a "party" by `groupBookingId`. Ours has **no group-booking field or derivation**.
- **Admin KPIs** (`adminKpis`, `selectors.ts:428-453`): GMV, navagooEarnings,
  pending transfers, open refund cases, flagged shops, pending activation. Ours has
  admin dashboards but no equivalent single selector; figures come from separate
  queries.
- **Customer classification** (`classificationForBooking`, `selectors.ts:205-211`):
  demo defaults to `shop_owned` unless a classification row says `navagoo_sourced`.
  Ours has no classification model — only `CustomerFreeze` (which the demo uses as
  ONE of four classification reasons, `types.ts:276-281`).

## 4. Finance engine (demo `lib/finance.ts`) — logic ours must match

Pure pipeline **Collect → Earn → Charge → Settle** (`finance.ts:1-7`). Highlights:
- `noVat` / `vatBreakdown` / `applyServiceDiscount` (`finance.ts:31-77`) — VAT is
  *inclusive* in stored prices; stripped via `/(1+vat%)`. Ours mirrors this exactly in
  `ShopService::calculateVat` (`ShopService.php:56-67`) and `getVat`/`getVatRate`
  (`:35-54`). **This is the closest parity point.**
- `marketingFeeDraft` / `paymentProcessingFeeDraft` / `notifFeeDraft` /
  `subscriptionChargeDraft` (`finance.ts:95-204`) — each emits a typed `ChargeDraft`
  with rate stamped at creation. Ours stamps equivalents onto `Earnings`
  (`navagoo_marketing_*`, `service_fee`) rather than discrete ledger rows.
- `refundZoneFor` / `customerRefund` (`finance.ts:318-356`) — hours-before-appointment
  → full/partial/none. Ours = `Booking::getRefundTypeForNow` (`Booking.php:45-87`)
  using `shop.refund_period_start`/`refund_period_end` + `partial_refund_value`.
  Same idea; see business.md §2.
- `isInGraceWindow` (`finance.ts:293`) vs ours `Shop.graceWindowEndsAt` — demo has a
  per-shop grace window for freeze-list upload (attribution). Ours: not clearly
  modeled as a window in the data layer.

See business.md for the numbered rules and parity.md for the line-by-line table.
