# Business rules — Shop · Booking detail + status timeline + actions

Numbered, implementable rules the demo encodes. "Demo ref" = file:line in
`/private/tmp/Navagoo_MI_dev/navagoo-app/src`. "Ours" = current state in `frontend/` + `common/`.

## Status workflow

**R1.** A booking has exactly five lifecycle statuses: `scheduled, in_progress, completed, no_show,
cancelled` (`status.ts:37`). Ours uses ints with extras: NEW(0), SELECTED_NOT_PAID(1), SCHEDULED(2),
COMPLETED(3), INPROGRESS(4), CANCELED(5), ACCEPTED(6), CANCELED_BY_SHOP(7), NO_SHOW(9)
(`common/models/base/Booking.php:73-81`). ACCEPTED is treated as scheduled; two cancel variants exist.

**R2.** Allowed forward transitions (default workflow, admin-overridable):
`scheduled → {in_progress, no_show, cancelled}`; `in_progress → {completed}`;
`completed / no_show / cancelled` are terminal (no transitions) (`status.ts:51-57`).
*Ours:* matches for the happy path but **No-show is also offered from in_progress** (R4) and the workflow
is **not admin-configurable** (hard-coded in `_detail_modal.php` + `BookingController.php:453`).

**R3.** Transitions resolve from ONE function `allowedNextStatuses` read by the detail body, the row
menu, and the store guard (`status.ts:64`). *Ours:* duplicated in view + controller, no single source.

**R4.** **No-show is allowed ONLY from `scheduled`** (it is absent from `in_progress`'s allowed set,
`status.ts:52`). *Ours:* offers No-show from SCHEDULED, ACCEPTED **and** INPROGRESS
(`_detail_modal.php:226`) — divergent.

**R5.** **No-show grace window:** a booking may be marked no-show only once
`now >= appointmentDate + noShowGraceMin` (`status.ts:93`). Before then the action is disabled (detail)
or hidden (menu) with the tooltip "No-show can be marked N min after the appointment start"
(`BookingDetailBody.tsx:242`, `modals.tsx:64`). *Ours:* **no grace gate at all.**

## Completion & collection

**R6.** **Completion hard gate:** you cannot mark a booking `completed` while
`outstandingBalance > 0.005`; the action instead opens the in-store collect flow, which collects the
balance and only then transitions to completed (`BookingDetailBody.tsx:60-63`, `modals.tsx:73-76`,
`CollectPaymentModal modals.tsx:978-982`). *Ours:* **no gate** — Complete posts directly and the server
auto-creates a Payment at `total_amount` (assumes paid) (`BookingController.php:463-491`).

**R7.** `outstandingBalance = max(0, bookingValue − amountCollected − inStoreCollected − refundValue)`,
rounded to 2dp (`finance.ts:404`). Presentation/gating only; `inStoreCollected` never enters fee/payout
math. *Ours:* model has `amount_collected`, `balance_due`, `deposit_amount` (`common/models/Booking.php:23`)
but no `inStoreCollected` / `refundValue` and no equivalent computed accessor surfaced in the body.

**R8.** **In-store collection:** cash or card; card payments may add an optional **tip** that is held by
the shop and paid to the specialist at tip settlement (`CollectPaymentBody modals.tsx:898`,
`collectPayment(id,{method,amount,tip})`). *Ours:* **no in-store collect UI / tip capture** in this area.

**R9.** **Collection status** for badges: `not_required` (nothing owed, nothing taken in person),
`collected` (nothing owed but in-store money taken), else `pending` (`finance.ts:412`). *Ours:* not
computed/shown in the detail header.

## Cancellation & refunds

**R10.** **Refund zone** from shop policy + cancel time: `full` if
`hoursBefore >= shop.cancelFullHours`, `partial` if `>= shop.cancelPartialHours`, else `none`
(`finance.ts:318`). A **shop-initiated** cancel forces `full` (`modals.tsx:179`). *Ours:* equivalent logic
exists as `Booking::getRefundTypeForNow()` using `shop.refund_period_start / refund_period_end` with a
past-appointment guard (`common/models/Booking.php:45-87`) — **but it is not wired into the cancel flow**.

**R11.** **Customer refund amount:** 0 if nothing collected; shop-cancel = full collected;
deposit timing = non-refundable (0); else `full`→full, `partial`→`amountCollected × partialRefundPct%`,
`none`→0 (`finance.ts:326`). *Ours:* not computed in the cancel flow (the marketing/refund engine is
elsewhere and disconnected here).

**R12.** **Marketing-fee disposition on cancel:** shop-cancel → fee stays (shop bears it);
customer-cancel → reversed in full (full zone), reversed `partialRefundPct%` (partial), or stays (none)
(`modals.tsx:240-247`). *Ours:* not surfaced in this flow.

**R13.** Cancel requires confirmation and records `cancelledBy`, `refundZone`, `refundReason`
(`modals.tsx:184-189`). *Ours:* records only an optional free-text `reason`; always sets
`CANCELED_BY_SHOP`; no by-whom / zone capture (`BookingController.php:686-704`).

**R14.** A **completed** booking cannot be cancelled. Demo: `cancelled` not in `completed`'s allowed set
(`status.ts:54`). *Ours:* enforced server-side — `actionCancel` excludes COMPLETED (`BookingController.php:694`). **Match.**

## Reschedule

**R15.** **Reschedule is a capability, not a status change.** Allowed only from statuses in
`rescheduleStatuses` (default `['scheduled']`) (`status.ts:78-89`). *Ours:* Reschedule button shown for
SCHEDULED|ACCEPTED only (`_detail_modal.php:206`). **Match (by default).**

**R16.** A reschedule must pass a **placement check** — no overlap, not on time-off, inside working
hours, specialist can perform the services — or confirm is blocked with an inline reason banner
(`modals.tsx:285-303`). *Ours:* `BookingScheduleService::checkPlacement` enforces the same four reasons
server-side inside a transaction (`BookingController.php:569`). **Match.**

**R17.** Reschedule increments a counter / preserves the slot mirror. *Ours:* increments
`reschedule_count` and rewrites `agent_slots` transactionally (`BookingController.php:579-596`). (Demo
tracks reschedule via store; functionally equivalent.)

## Permissions & scoping

**R18.** All booking detail/transition/cancel/reschedule actions are **shop-scoped** — a shop user may
only act on bookings belonging to their shop. *Ours:* every action calls `$this->checkOwnership($model)`
(`BookingController.php:433, 450, 538, 698`). Demo is single-tenant simulation (store-scoped by `shopId`).
**Match (ours enforces real auth).**

**R19.** Server transition allow-list: only `{in_progress, completed, no_show}` accepted by
`actionTransition` (`BookingController.php:453`); cancellation goes through the dedicated `actionCancel`
so the customer notification fires (`BookingController.php:701`). This is an ours-specific guard (sound),
but it does **not** enforce the *from*-status workflow (e.g. it would accept `completed` from `scheduled`,
skipping `in_progress`) — see R2.

## Money / VAT

**R20.** Demo prices are **VAT-inclusive**; VAT is split only for the catalogue editor display via
`vatBreakdown` (`finance.ts:64`) and never feeds booking math. *Ours:* the detail body shows an explicit
**VAT** line and a separate Total (`_detail_modal.php:181-188`) — an ours-specific presentation; verify it
does not double-count against a VAT-inclusive subtotal.
