# Business Rules — Logic / data model area

Numbered, implementable rules the demo encodes (`types.ts`, `lib/status.ts`,
`lib/finance.ts`, `store/selectors.ts`). Each notes our parity.

## 1. VAT & pricing

1. Stored catalogue `price` is **VAT-inclusive** and **after-discount**; it is what
   the booking engine consumes (`types.ts:212-227`). OURS: `ShopService.price` is the
   priced copy; `price_excl_vat_after_discount` is also stored (`base/ShopService.php`).
2. VAT is stripped by `amount / (1 + vatPct/100)`; if the shop is **not**
   VAT-registered the amount is unchanged (`finance.ts:31-34`). OURS: identical in
   `ShopService::calculateVat` (`ShopService.php:56-67`); registration check =
   `Shop.is_taxable` (`base/Shop.php`). **DONE.**
3. Global `vatPct = 15`, `marketingFeePct = 5`, `paymentProcessingFeePct = 3.5`,
   `paymentProcessingFeeFixed = 1`, `minMarketingFee = 5` (`types.ts:520-535`). OURS:
   stored in `backend\models\Settings` row 1 (`taxes`, `taxes_type`,
   platform/marketing fee fields). Values not verified equal — PARTIAL.
4. Catalogue discount: `fixed` subtracts (floor 0); `percent` clamps 0–100
   (`applyServiceDiscount`, `finance.ts:43-51`). OURS: `ShopService.discount_type`
   = PERCENTAGE(1)/FIXED_AMOUNT(2) (`ShopService.php:16-25`). Note: demo type values
   are strings `'percent'|'fixed'`; ours are ints 1/2 — mapping required.

## 2. Cancellation / refund zones

5. Refund zone by hours before appointment: `> cancelFullHours` → full;
   between full/partial → partial (`partialRefundPct`); else none
   (`refundZoneFor`, `finance.ts:318-324`). OURS: `Booking::getRefundTypeForNow`
   (`Booking.php:45-87`): `> refund_period_start` → FULL; `<= start && >= end` →
   PARTIAL; else NO_REFUND. **Note the window semantics differ** — demo uses two
   independent thresholds (full, partial), ours uses a start/end band. Verify mapping.
6. If the customer never paid (no Payment row), full refund freedom is allowed
   (ours, `Booking.php:56-59`). Demo computes refund off `amountCollected`; an
   uncollected booking yields 0 refund either way.
7. A past appointment → NO_REFUND (ours guards `$interval->invert`, `Booking.php:67-71`).
   Demo: refund is computed at cancel time vs appointment; same intent.

## 3. Booking status state machine

8. Canonical statuses (demo): `scheduled | in_progress | completed | no_show |
   cancelled` (`types.ts:298`). OURS has 9 numeric statuses incl. NEW,
   SELECTED_NOT_PAID, ACCEPTED, and a separate CANCELED_BY_SHOP
   (`base/Booking.php:73-81`). Mapping required; PARTIAL.
9. Allowed transitions (demo default, admin-editable): scheduled →
   [in_progress, no_show, cancelled]; in_progress → [completed]; completed / no_show /
   cancelled → [] (terminal) (`status.ts:51-57`). OURS: **no enforced transition map** —
   any status can be set; only ad-hoc controller checks. MISSING.
10. `cancelled` keeps its own `cancelledBy` (`customer|shop`) + `refundZone`
    (`types.ts:341-342`). OURS encodes who-cancelled as two distinct status values
    (CANCELED vs CANCELED_BY_SHOP) plus `CancellationsAndRefunds` rows. Different shape.
11. No-show only allowed after `noShowGraceMin` past appointment start
    (`canMarkNoShow`, `status.ts:93-95`). OURS: `Shop.no_show_threshold_minutes`
    (default 15, 5–30) exists (`base/Shop.php:169-171`) — enforcement not verified.
12. Reschedule is **not** a status change; allowed only from statuses in
    `rescheduleStatuses` (default `['scheduled']`) (`status.ts:78-90`). OURS:
    `Shop.reschedule_limit` + `Booking.reschedule_count` = a count cap, NOT a
    status gate. Semantics differ — PARTIAL.

## 4. Payment timings & methods

13. `paymentTiming = online | deposit | on_visit` (`types.ts:295`). OURS:
    `Booking.PAYMENT_MODE_ONLINE|DEPOSIT|ON_VISIT` (`Booking.php:91-93`). **DONE** (values
    match exactly).
14. A shop toggles which methods it accepts (`acceptOnline`, `acceptDeposit`,
    `acceptOnVisit`) + a separate walk-in gating (`walkinOnline/Deposit/OnVisit`)
    (`types.ts:43-55`). OURS: `ShopPaymentSettings.pay_online_enabled /
    pay_deposit_enabled / pay_on_visit_enabled`, "at least one must be enabled"
    validation (`ShopPaymentSettings.php`). **No separate walk-in method gating** —
    PARTIAL.
15. Deposit `%` is per-shop, capped by `maxDepositPct` (per-shop override else global)
    (`types.ts:47-50`). OURS: `ShopPaymentSettings.deposit_percentage` (1–100), capped by
    `Shop.max_deposit_percent_override` else global `Settings.max_deposit_percent`,
    hard cap 100 (`ShopPaymentSettings.php`). **DONE.**
16. `amountCollected` = money on the Navagoo **rail** only; in-store cash/card is
    off-rail and never produces a processing fee or enters settlement
    (`types.ts:319-327`). OURS: `Payment.paid_amount` is the rail; there is no explicit
    `inStoreCollected`/`inStoreMethod` field — off-rail in-store collection is not
    modeled distinctly. MISSING.
17. Specialist card tip is a shop→specialist liability, off-rail, settled later
    (`specialistTip`, `specialistTipSettled`, `types.ts:329-334`). OURS: tips live on the
    booking (`tipping_amount`, `tipping_status`, `tipping_invoice_id`) and
    `Earnings.tip_amount` with EARNING_TYPE_TIP — tip flows through Navagoo, not a
    pure off-rail specialist liability. PARTIAL/different.

## 5. Settlement (Collect → Earn → Charge → Settle)

18. A booking becomes settlement-eligible when terminal AND its settlement fees are
    done AND `daysBetween(now, transactionDate) >= settlementHoldDays` AND not already
    in a transfer/settled (`isEligible`, `finance.ts:472-477`; `eligibleTransferBookings`,
    `selectors.ts:167`). OURS: `Earnings.settlement_status` lifecycle +
    `Shop.minimum_elapsed_period_days` (`base/Shop.php`). Conceptual parity, different
    mechanism — PARTIAL.
19. Withdrawal requires `eligibleNet >= minWithdrawalAmount` (default 5000)
    (`shopKpis.meetsWithdrawalMin`, `selectors.ts:367`). OURS:
    `Shop.minimum_withdrawal_amount` (`base/Shop.php`). **DONE** (field exists).
20. Each fee is a typed `charges` row with rate **stamped at creation, never
    recomputed** (`finance.ts` header; `types.ts:393-411`). OURS: fees denormalized onto
    each `Earnings` row (`navagoo_marketing_percentage/_fees`, `vat_navagoo`,
    `navagoo_net_fees`, `service_fee`) — also stamped per row, but **no separate
    append-only ledger and no reversal rows** (`reversalOfId`). PARTIAL.
21. Reversals net against originals via a negative `reversalOfId` charge
    (`reversalDraft`, `finance.ts:205`). OURS: refunds handled via
    `CancellationsAndRefunds` + `Earnings.EARNING_TYPE_REFUND_DEDUCTION` /
    `refund_value`. Different shape — PARTIAL.

## 6. Customer attribution / classification

22. A customer is `navagoo_sourced` or `shop_owned`; default `shop_owned` unless a
    classification row says otherwise (`classificationForBooking`, `selectors.ts:205`).
    Reasons: `freeze_list | app_first_booking | deep_link | shop_admin_walkin`
    (`types.ts:276-281`). OURS: **no classification entity**; only `CustomerFreeze`
    (the freeze-list reason). Marketing-fee scope (which bookings incur the 5%
    marketing fee) depends on this in the demo. MISSING (largest logic gap).
23. Freeze-list entries are uploaded during the shop's grace window
    (`graceWindowEndsAt`, `isInGraceWindow`, `finance.ts:293`). OURS: `CustomerFreeze`
    exists but the grace-window gating is not modeled as a data field. PARTIAL.

## 7. Catalogue lifecycle & linking

24. Effective customer-app visibility = `active !== false && !hidden`
    (`isShownInApp`, `selectors.ts:108-109`). OURS: `ShopService.status`
    ACTIVE/ARCHIVED only — single flag, no separate `hidden` (`ShopService.php:14-15`).
    PARTIAL.
25. A service performed by linked specialists; **empty link list = any specialist**
    (`specialistsForService`, `selectors.ts:116-128`). OURS: `UserShopService` pivot;
    "empty = anyone" rule not verified — PARTIAL.
26. Service total duration = base + attached routine/add-on (freebie) minutes
    (`totalDuration`, `selectors.ts:99-102`). OURS: no freebie concept — MISSING.

## 8. Shop scoping (multi-tenant)

27. Every catalogue/booking/charge entity carries `shopId`; all selectors filter by it
    (`selectors.ts:44-55`). OURS: every model has `shop_id` and queries scope by it
    (`BookingQuery`, `ShopServiceQuery`, etc.). **DONE.**
28. Branches: `parentShopId` (demo, wired M6) (`types.ts:63`). OURS:
    `Shop.parent_shop_id` + `inherit_parent` (`base/Shop.php`). **DONE.**
