# Shop · Bookings calendar — Business rules (numbered, implementable)

Rules the demo encodes for the calendar/scheduling area, each with the demo ref and our
status. "Ours" = `BookingScheduleService` (BSS) / `BookingController` (BC) /
`AgentsBookingsController` (ABC) unless noted.

## Placement / conflict

1. **A move/placement is rejected if the new interval overlaps another non-cancelled
   booking on the same specialist that day.** Half-open overlap (`a.start < b.end && b.start < a.end`).
   Demo `schedule.ts:88,317-328`. Ours BSS::checkPlacement :278-298, `overlaps` :263. **DONE.**

2. **Rejected if the interval intersects any time-off block** (specialist-scoped *or*
   shop-wide). Demo `schedule.ts:330-334`. Ours BSS :300-305 (`timeOffBlocks` includes
   `agent_id IS NULL` shop-wide rows, :161). **DONE.**

3. **Rejected if the interval is not fully inside one of the specialist's working shifts**
   for that day. Demo `schedule.ts:336-340`. Ours BSS :307-317. **DONE.**

4. **On cross-specialist reassign, the target specialist must be able to perform every
   booked service.** A service with no linked specialists is performable by anyone.
   Demo `schedule.ts:289-295,342-345` (`service.specialistIds`). Ours BSS::canPerform :250-259
   against `user_shop_service`; only enforced on reassign (serviceIds passed). **DONE.**

5. **Rejection precedence is overlap → time-off → outside-availability → cannot-perform**
   (first failing reason wins, drives the toast/alert text). Demo `schedule.ts:312-347` +
   `placementReasonText` :352. Ours BSS :273-325 + `reasonMessage` :327. **DONE.**

6. **Placement is re-validated server-side, not trusted from the client.** Demo: the store
   setters `rescheduleBooking`/`reassignBooking` re-run `checkPlacement` as the authoritative
   guard even after the UI pre-checked (store.ts:1381-1396, 1419-1433). Ours: BC::moveBooking
   re-runs BSS::checkPlacement before writing (:569). **DONE.**

## Reschedule / reassign permissions

7. **Only "reschedulable" statuses may be dragged/moved** (admin-config; demo default =
   scheduled only). Once in-progress/completed/no-show/cancelled the block is frozen.
   Demo: `canRescheduleStatus(status, config.rescheduleStatuses)` gates draggability
   (`SpecialistColumn.tsx:235`) and the store setters (store.ts:1381,1419). Ours:
   `BSS::isDraggable` → only `STATUS_SCHEDULED` + `STATUS_ACCEPTED` (:42-45), used to set the
   block's `draggable` flag (BSS::dayLayout :552). **PARTIAL** — ours hard-codes the two
   statuses (no admin-config `rescheduleStatuses` equivalent), and the **server move endpoint
   does not re-check draggability** (BC::moveBooking validates placement but not status), so a
   crafted POST could move a completed booking. Tighten BC::moveBooking to reject
   non-draggable statuses.

8. **Drag snaps the new start to the shop's slot step and clamps inside the day window.**
   Demo `DayCalendar.tsx:141-142` (`snapToStep(slotStepMin)`, clamp `[winStart, winEnd-dur]`).
   Ours `_calendar_day.php:303-305` (same snap+clamp, slot step from `shop.slot_time_step`,
   BSS::dayLayout :494). **DONE.**

9. **A cross-column drop is a reassign (specialist changes); a same-column drop is a
   reschedule (time only).** Demo `DayCalendar.tsx:143,200-206`. Ours `_calendar_day.php:311`
   (compares target vs source agent) → BC::actionReschedule/:actionReassign. **DONE.**

10. **Reschedule/reassign must notify the customer of the new time (and specialist).**
    Demo promises this in the confirm dialog copy (`DayCalendar.tsx:191-195`). Ours:
    **MISSING** — BC::moveBooking sends no notification (NotificationHelper is only used by
    actionCancel, BC:701). Behavioural promise unmet.

11. **A reschedule/reassign confirmation is required before committing the drag.**
    Demo confirms BOTH reschedule and reassign (`DayCalendar.tsx:171`). Ours: only reassign
    confirms (`_calendar_day.php:317`); reschedule commits silently. **PARTIAL.**

## Slot search (reschedule picker / new walk-in)

12. **Open slots are enumerated by stepping the slot step across each free block and
    keeping starts that pass `checkPlacement`.** Demo `SlotPicker`/store logic; ours
    `BSS::freeSlots` :348-376. **DONE.**

13. **Past times on the current day are not offered as open slots.** Ours adds
    `isToday && start < now` filter (`BSS::freeSlots:360`); demo SlotPicker also clamps the
    picker to today (`_reschedule_modal.php` mirrors with `prev` disabled ≤ today :118).
    **DONE / ours arguably stricter.**

## Calendar membership / display

14. **Day columns = active specialists working that day, PLUS any specialist (off/inactive)
    who still holds a booking that day, shown muted (not a drop target).** Demo
    `schedule.ts:269-285`; muted columns disabled as droppable (`SpecialistColumn.tsx:82-86`).
    Ours `BSS::calendarColumns:384-410`; muted columns omit `data-col-body` so drag/click
    do nothing (`_calendar_day.php:117`), and `draggable` is forced false on muted (:552).
    **DONE.**

15. **Cancelled bookings are excluded from the calendar; only "active" statuses occupy
    grid space.** Demo `bookingsForShopOnBusinessDay` filters `status !== 'cancelled'`
    (schedule.ts:240). Ours uses `Booking::statusesFilter()` (BSS::activeStatuses :36, used
    in column/booking queries :393,:501) — excludes NEW/not-paid carts and cancelled.
    **DONE** (ours is broader: also drops not-paid carts, which is correct for a salon grid).

16. **A booking's scheduled duration drives its block height; a zero/unknown duration is
    floored so the block stays clickable.** Demo floors at 15min (`bookingMinutes`
    schedule.ts:166). Ours: end derived from `to_hour` or `getScheduledDuration()` else +60min
    (BSS::dayLayout :526-528; presenter :110-113); min block height floored at 22px on render
    (`_calendar_day.php:171`). **PARTIAL** — ours has a px floor but no 15-min duration floor,
    so a true 0-min booking gets a 60-min fallback (different but harmless).

17. **The visible window expands beyond open→close to cover any overflowing booking, shift
    or time-off, then snaps to whole hours.** Demo `dayBounds` schedule.ts:416-432. Ours
    BSS::dayLayout :558-593. **DONE.**

## Time-off ("block time")

18. **Time-off scope is specialist OR whole-shop; a whole-shop block applies to every
    column.** Demo `NewTimeOffModal.tsx` scope segmented; `timeOffForBusinessDay` returns
    shop-scope to all (schedule.ts:204-207). Ours `_timeoff_modal.php` scope toggle →
    `agent_id` null for shop-wide (BC::actionTimeOff :648-649); `timeOffBlocks` OR-matches
    null agent (:161). **DONE.**

19. **All-day block spans the shop's open→close window.** Demo `NewTimeOffModal.tsx:54-57`
    (`shopWindow`). Ours BSS::timeOffBlocks :168-170. **DONE.**

20. **A non-all-day block whose end ≤ start is treated as crossing midnight (+1 day).**
    Demo `NewTimeOffModal.tsx:61`. Ours BSS::timeOffBlocks :177-179, BC also relies on stored
    hours. **DONE.**

21. **A per-specialist time-off may only target a specialist belonging to the current shop;
    removal is shop-scoped.** Ours BC::actionTimeOff :657-663 (validates agent∈shop),
    actionRemoveTimeOff :672-683 (`AgentTimeOff::findOne([id, shop_id])`). Demo is
    single-tenant so has no equivalent server guard. **DONE / ours stronger.**

## Shop-scoping & permissions (server, no demo equivalent — demo is single-tenant)

22. **Every calendar read/write is scoped to the logged-in user's `shop_id`.** BC::actionCalendar
    :374, actionSlots :619, actionTimeOff :644, move :540; `checkOwnership` on detail/move
    (BC:433,538). ABC::actionView 403s cross-shop (:308). **DONE.**

23. **`reschedule_count` is incremented on every move.** Ours BC::moveBooking :585. No demo
    equivalent (demo has no such field). **OURS-ONLY (correct).**

24. **Moving a booking rewrites its `agent_slots` mirror in a transaction.** Ours
    BC::moveBooking :577-598 (delete + recreate). No demo equivalent (no slot table). **OURS-ONLY.**

## Financial invariants (touched by walk-in create from empty slot)

25. **Pricing is VAT-inclusive at 15%; end time / duration is derived, never affecting the
    ledger.** Demo keeps the schedule layer ledger-free ("End times are always DERIVED…
    finance stays untouched", schedule.ts:1-17). Ours computes `vat = subtotal * 0.15` on
    walk-in/booking save (BC::actionCreate :194, ABC via `BookingForm`). The move endpoints
    only touch time/agent, never amounts. **DONE** (VAT rate hard-coded 0.15 in BC;
    `ShopService::getVatRate()` used elsewhere — minor inconsistency to confirm).
