# Group Bookings — Logic & Flows (parity)

Area: **Shop · Group ("party") bookings** — NEW in the dev demo (milestone M2).
Demo: React + TS + Zustand at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`.
Ours: Yii2 advanced template (cwd).

> **Bottom line:** Group bookings are **entirely absent** from our codebase. There is no
> `group_booking_id` / `guest_label` column on `booking`, no model relation, no service,
> no controller, no view, no API route, no i18n. This doc captures the demo's full logic
> so it can be implemented; every "our ref" below is `—` unless noted.

## 1. Core data model (demo)

A **party** = N child `Booking` rows that share one `groupBookingId` (`GRP-…`).
Each child is an otherwise-normal booking — same finance/attribution/schedule pipeline —
so the rest of the app is untouched. Two extra fields on `Booking`:

- `groupBookingId?: string` — `GRP-…` when the booking belongs to a party.
- `guestLabel?: string` — e.g. `"Sara (organiser)"`, `"Guest 2"`; only on group children.

Demo ref: `src/types.ts:309-357` (Booking), `:351-357` (group fields).

The **organiser is the customer-of-record**: every child shares the same `customerId`.
Extra guests are *not* customers — they are just `guestLabel` strings on each child.

Our ref: `common/models/base/Booking.php` — no group/guest/party column (grep returns
nothing). `common/models/Booking.php`, `BookingService.php` carry no party concept.

## 2. Create a party — `createGroupBooking`

Demo ref: `src/store/store.ts:1459-1612`. Input shape `NewGroupBookingInput`
(`store.ts:329-337`): `{ shopId, customerId? | newCustomer?, appointmentDate, paymentTiming,
source, guests: [{ label?, specialistId, serviceIds[], discount? }] }`.

Flow (all validation up front; `set()` only at the very end → **atomic**):
1. Shop must exist; ≥1 guest; **every** guest needs ≥1 service.
2. **Distinct specialist per guest** — all guests share one start time, so two guests on
   one specialist would self-overlap. Reject if any specialist repeats (`store.ts:1468-1473`).
3. Resolve organiser: existing `customerId` or create from `newCustomer` (mirrors
   `createBooking`) — `store.ts:1475-1487`.
4. **Attribution computed ONCE on the organiser** and shared by the whole party
   (`classifyCustomer` by source app/deep_link/walk-in + freeze list) — `store.ts:1489-1519`.
5. Allocate one `groupBookingId` (`newGroupBookingId()` → `GRP-…`).
6. For each guest build a child `Booking` (lines, subtotal, per-guest `discount`,
   `bookingValue`, deposit/online `amountCollected`, `status:'scheduled'`,
   `guestLabel: label || "Guest N"`), then `checkPlacement` it against existing
   bookings/availability. Siblings aren't in state yet, but rule 2 guarantees no
   intra-party overlap. Any placement failure → return `{ error }`, nothing persisted
   (`store.ts:1530-1592`).
7. Derive charges per child via the **same** `deriveBookingCharges` used for solos
   (`store.ts:1587`). Commit bookings+charges+customer+classification+activity, then
   `reconcileBilling()` (`store.ts:1594-1611`).
8. Return `{ groupBookingId }`.

Each child slot duration = Σ its own service durations, floored 15 min; guests run **in
parallel**, so the party's slot length = `max` over guests (`selectors.ts:254-263, 290`).

Our ref: solo booking only — `api/models/BookingForm.php`, `common/models/Booking.php`,
`frontend/components/BookingScheduleService.php`. No multi-child / atomic-party path. **MISSING.**

## 3. Reschedule the party — `rescheduleGroupBooking`

Demo ref: `store.ts:1614-1657`. Re-validates **every** child at the new shared start
*before* moving any (atomic). Children whose status can't be rescheduled
(`canRescheduleStatus`) are skipped. On any placement clash → `{ error }`, none moved.
Each guest keeps their specialist + services; only the start time changes. **MISSING.**

## 4. Cancel the party — `cancelGroupBooking`

Demo ref: `store.ts:1659-1665`. Loops children and calls the per-booking
`transitionBooking(id,'cancelled',opts)` — reusing the workflow gate + refund calc +
ledger. Opts carry `cancelledBy` + `refundZone`. **MISSING.**

## 5. Collect for the party — `collectGroupPayment`

Demo ref: `store.ts:1667-1684`. Organiser settles the whole party: collect each guest's
`outstandingBalance` via per-booking `collectPayment`. A single card **tip attaches to the
FIRST guest with a balance** only, to avoid double-counting one party tip. **MISSING.**

## 6. Complete the party — `completeGroupBooking`

Demo ref: `store.ts:1686-1693`. Loops children → `transitionBooking(id,'completed')`. The
per-booking **collect-to-complete gate** still applies (a guest with a balance won't
complete; UI routes through collect-all first). **MISSING.**

## 7. Group selectors

Demo ref: `selectors.ts:240-306`.
- `groupOf(s, gid)` — children in creation order (organiser first), `:266-271`.
- `summariseGroup` → `GroupSummary { organiser, appointmentDate(first child), bookings,
  partySize, status('mixed' when children differ), paymentTiming, value=Σ bookingValue,
  revenue=Σ bookingRevenue, outstanding=Σ outstanding, durationMin=max child }`, `:273-292`.
- `groupsForShop(s, shopId)` — all parties at a shop, newest slot first, `:295-306`.

Our ref: `common/models/search/BookingSearch.php` aggregates solo bookings only; no
GROUP BY `group_booking_id`, no party summary. **MISSING.**

## 8. Finance invariance (a group child is just a Booking)

Demo test `store/groupBooking.test.ts:185-229`: a one-guest party with the same
service/timing/source derives **identical** charges to an equivalent standalone booking.
This is the design contract — implementing groups must not fork the finance pipeline.
On our side finance lives in the earnings/charge tables (`m251019_213708_add_shop_earning_tables.php`)
and would need the same invariance if groups are added.

## Summary of gaps
Everything in §§2–8 is **MISSING** on our side. The feature does not exist in any layer
(DB, model, service, controller, view, API, i18n).
