# Group Booking — Backend Delivery Note → Mobile Team

**Re:** your `BACKEND_GROUP_BOOKING_SPECIFICATIONS.md` · **From:** backend · **Date:** 2026-08-09
**Status: ✅ every item on your checklist is LIVE** on `tailwind-poc` (latest commits `c87069d`, `87d9cef`; earlier `0036c0b`, `47ab9bf`).
**Companion doc:** `GROUP_BOOKING_MOBILE_FLOW.md` — the full step-by-step contract with request/response samples (updated today; treat it as the source of truth for shapes).

---

## ✅ UPDATE 2026-08-11 — response to `BACKEND_GROUP_BOOKING_AUG_11.md` (Issues A/B/C)

### Issue A — cancel-participant "guest stays active" → **FIXED (our bug, sorry)**
Root cause: this delivery note documented the payload as **`{booking_id}` alone** (as your
spec asked), but the implementation ALSO required `group_booking_id` — so your calls
returned **404 Not Found** and the guest was never cancelled. Now `{booking_id}` alone
works (the party is resolved from the child, customer-scoped); `group_booking_id` is still
accepted when sent. **Check the HTTP status of your calls** — if you were treating the 404
as success, that alone explains "still returned as active".

Also per your A.1: the group aggregates now **re-compute after a cancellation** —
`party_size` / `value` / `outstanding` count **non-cancelled** children only (a new
`party_size_total` keeps the original headcount; cancelled guests stay visible in
`children[]` + `status_summary`). `cash_due_now` / `amount_paid_now` were already correct.
A.2 (last remaining guest): unchanged by design — you get the **422** with the explicit
"cancel the whole party instead" message, exactly as your original spec requested; call
`cancel-group` on that response.

### Issue B — cancelled party still in `old=0` → **cannot reproduce; likely fallout of A or a stale QC**
Server-side the semantics are correct and now **pinned by a regression test**: create
party → `cancelAsCustomer` → every child is status **5** → the exact `old=0`
(+`collapse_groups=1`) conditions return **0 rows**. Two likely explanations on your side:
1. The "cancelled" guest was never cancelled (Issue A's 404) — retest now that A is fixed.
2. QC running an older build — make sure QC pulled ≥ `cda4c0f` (and now this fix).

If it still reproduces after both: send us the RAW `cancel-group` response body + the raw
`booking/index?old=0&collapse_groups=1` response and we'll trace it same-day.

### Issue C — `payment_mode` for SINGLE bookings → **SHIPPED**
- **`POST /booking/book` now accepts optional `payment_mode`**: `"on_visit"` books **and
  confirms in one call** (status → 2, `balance_due` = total — no `/pay`, no
  `confirm-on-visit` needed); `"deposit"`/`"online"` stamp the intent and the booking stays
  pending until `POST /booking/pay` (which keeps accepting `payment_mode` at settlement,
  as before). Validated against the shop's `allowed_payment_methods` (422 otherwise).
  Omitted ⇒ the current flow, byte-identical.
- **Single-booking responses** now carry the full breakdown: `payment_mode`,
  `deposit_amount`, `amount_collected`, `balance_due`, **`refund_value` (new)** +
  `payment_message` — same vocabulary as group children.
- `book-package` note: that route is the **legacy** package system. The real
  session-package redemption (`POST /subscription-package/redeem`) is **pre-paid by
  definition** — there is no payment_mode to choose; the booking comes back
  `payment_mode:"package"`, cash fields 0.

---

## ✅ UPDATE 2026-08-10 — your answers received & IMPLEMENTED

Your `answers_to_questions_1_to_10.md` is processed. Everything you asked for is now live:

| Your answer | Delivered |
|---|---|
| **Q3 — `cash_due_now`** | ✅ New server-computed key in **create (201), view, and pay** responses: the exact amount to pass to your Cloud Function. `0.0` for on_visit / all-package parties and after settlement. Never compute the charge client-side again — read this one number. |
| **Q5 — `status_summary`** | ✅ New object in the same responses, e.g. `"status_summary": { "scheduled": 2, "canceled": 1 }` (snake_case keys: `new, selected_not_paid, scheduled, completed, inprogress, canceled, accepted, canceled_by_shop, no_show`; only non-zero counts appear). |
| **Q6 — `collapse_groups=1`** | ✅ `GET /booking/index?collapse_groups=1` now returns **one row per party** (the organiser child represents it, still carrying `group_booking_id` + `is_group_booking` for routing). Omit the param → old behavior (row per guest). |
| **S1 — webhook settlement** | ✅ The Paymob webhook now settles a group party server-side when the app dies after paying — **but it needs ONE change in YOUR Cloud Function; see the red box below.** |
| **S4 — customer push** | ✅ Three organiser pushes are live: **created** ("complete the payment…" — or "confirmed" immediately for on_visit/all-package), **confirmed** (fires from BOTH `/pay` and the webhook path), **guest cancelled** ("Guest X was cancelled, the rest of the party is unchanged"). Bilingual (ar/en). Push `data`: `{ "type": "group-booking", "id": "<group_booking_id>", "route": "navagoo://group/details/<group_booking_id>" }` — route taps to `GroupBookingDetailsScreen`. |
| Q1/S2 (declined), Q2, Q4, Q7–Q10 (confirmed) | Nothing to build — noted. Your mixed-payment copy (Q4) needs no extra fields: drive it from `amount_paid_now` + any child having `payment_mode:"package"`. |

> ### 🔴 ACTION REQUIRED in your Firebase Cloud Function (for S1 to work)
> When creating the Paymob **order** for a group booking, set the order's
> **`merchant_order_id` = the party id** — e.g. `GRP-2608-75ZYI` (append `_2`, `_3`…
> on payment retries: Paymob requires merchant_order_id to be unique, and we parse
> only the `GRP-YYMM-XXXXX` prefix). That is how the webhook maps a transaction to
> the party when the app never got to call `/group-booking/pay`. Solo bookings are
> untouched — anything not matching the `GRP-` pattern behaves exactly as before.
> Until you ship this, S1 simply stays inert (no harm, no behavior change).
>
> Settlement remains **idempotent by transaction id** on both paths: whichever of
> webhook / `/pay` lands first settles the party; the other becomes a no-op success.

This note has four parts: **(A)** what you asked ↔ what shipped, **(B)** contract corrections where reality differs from the guesses in your samples, **(C)** integration how-tos, **(D)** questions we need YOUR answers on + suggestions you can opt into.

---

## A. Your checklist — delivered

| # | You asked for | Status | Where / notes |
|---|---|---|---|
| 1 | `GET /group-booking/view/{id}` with party details, totals, per-child arrays | ✅ LIVE | Now also returns your §1/§3 aggregates: `amount_paid_now`, `deposit_percentage`, group-level `payment_mode`, organiser `name`, `shop{id,name,address,lat,lng,cancellation_policy}`, `invoice` (PDF URL), and per-child `services[{id,name,price,period}]` |
| 2 | `POST /group-booking/cancel-participant` with last-guest rule | ✅ LIVE | Payload `{booking_id}`. Last remaining guest → **HTTP 422** with the exact message *"This is the last guest left in the party. Cancel the whole party instead."* — matches your rule verbatim |
| 3 | `POST /group-booking/cancel-group` | ✅ LIVE | Payload `{group_booking_id}`. Per-child refund zones apply through the finance ledger |
| 4 | `amount_paid_now` / `outstanding` / `deposit_percentage` in **create & view** payloads | ✅ LIVE | One shape (`shapeGroup`) serves **create (201), view, and pay** — so the success screen can be rendered from any of the three |
| 5 | Row-lock conflict checks on create + 0-based `participant_index` on error | ✅ LIVE (was already) | `SELECT … FOR UPDATE` on the specialists' day + all-or-nothing rollback. 422 (pre-validation) **and** 409 (lost-race) both carry `errors.participant_index` |
| 6 | §2: identify group bookings in `GET /booking/index` | ✅ LIVE | Every booking item now carries `group_booking_id` (null for solo) **and** `is_group_booking` (bool) — you asked for either; you got both |
| 7 | §3: success-screen money fields for all 3 payment scenarios | ✅ LIVE | See B.3 below for the exact field↔scenario mapping |
| 8 | §4: busy intervals + shifts + all-or-nothing + structured conflict errors | ✅ LIVE (was already) | `POST /booking/agent-slots` shape unchanged; your day-ID assumption is **confirmed correct** (see B.5) |

---

## B. Contract corrections — where the real API differs from your doc's samples

Please align the app to these (they're small, but they'll bite silently if hardcoded):

### B.1 `payment_mode` values are **lowercase**
`"online" | "deposit" | "on_visit" | "package"` — both per-child and group-level. Your §1 sample shows `"DEPOSIT"` for children; that casing does not exist in the API.
Group-level `payment_mode` = the **cash** mode chosen at create; it is `"package"` only when **every** guest redeemed a package session.

### B.2 Booking `status` enum — actual values
Your sample used `status: 2` correctly, but for reference the full enum is:

| Value | Meaning |
|---|---|
| 0 | NEW |
| **1** | **SELECTED_NOT_PAID** — pending payment (online/deposit before `/pay` confirms) |
| **2** | **SCHEDULED** — confirmed |
| 3 | COMPLETED |
| 4 | INPROGRESS |
| 5 | CANCELED (by customer) |
| 6 | ACCEPTED |
| 7 | CANCELED_BY_SHOP |
| 9 | NO_SHOW |

⚠️ **Group-level `status` can also be the string `"mixed"`** when children are in different states (e.g. after one guest is cancelled). Type it as `int | "mixed"`, not `int` (your §1 typed it as int). See question Q5.

### B.3 Success-screen fields ↔ your §3 scenarios (exact mapping)

| Your scenario | Read these keys from create/pay/view |
|---|---|
| `payFullAmount` (online) | after pay: `amount_paid_now` = full total, `outstanding` = 0 |
| `payDepositAmount` | after pay: `amount_paid_now` = the deposit, `outstanding` = balance at venue, `deposit_percentage` = the % |
| `payAtVisit` | at create: `amount_paid_now` = 0, `outstanding` = full total (party already CONFIRMED — no pay step) |
| *(package guests)* | excluded from all cash totals; child has `payment_mode:"package"`, `balance_due: 0` |

Notes: `value` = party total (all guests incl. package). `amount_paid_now` = Σ children `amount_collected` — it is **0 in the create response** for online/deposit (nothing captured yet) and becomes the paid amount in the **pay response**. Render the success screen from the **pay** response for online/deposit, and from the **create** response for on_visit / all-package.

### B.4 Misc shapes
- `group_booking_id` format: **`GRP-YYMM-XXXXX`** (your sample `GRP-2608-75ZYI` is exactly right).
- `shop.lat` / `shop.lng` are **strings** (e.g. `"24.7136"`), not floats — parse them.
- `shop.cancellation_policy` is **free text** (the shop's cancellation terms), not structured. See Q9 if you want structured zones.
- `invoice` is **null until `/group-booking/pay` settles**; the pay response (and any later view) carries the PDF URL. One invoice per party (single Paymob transaction).
- `deposit_percentage` is null when the shop's deposit mode is off.

### B.5 Shifts day-IDs — your assumption is CONFIRMED
Backend day ids are **1=Saturday … 7=Friday** (`user_shifts` entries encode `"dayId:HH:mm"`). Your Dart mapping table in §4-A-2 is correct as written.

### B.6 Deposit math — read, don't recompute
The deposit is rounded **per child** then summed: `Σ round(child.total_amount × pct/100, 2)`. If the app recomputes `round(Σ totals × pct/100, 2)` instead, it can drift by a halala on odd totals and then fail our server-side amount guard. **Recommendation: never compute the charge yourself — sum our per-child fields** (see C.2), or answer Q1/Q3 and we'll expose a server-computed `cash_due_now` field so you read a single number.

---

## C. How-to (integration recipes)

### C.1 Routing from the bookings list
```dart
if (item.group_booking_id != null)  -> GroupBookingDetailsScreen(GET /group-booking/view/{group_booking_id})
else                                -> RequestScreen (solo)
```
`is_group_booking` is the same signal as a bool — use whichever is cheaper for your models, they will never disagree.

### C.2 Amount to charge via Paymob (online/deposit)
From the **create** response:
```
cash_children = children.where(status == 1 /* SELECTED_NOT_PAID */)
online : charge = Σ cash_children.total_amount
deposit: charge = Σ round(child.total_amount × deposit_percentage / 100, 2)   // per child, then sum
```
Charge exactly that via your Paymob SDK, then `POST /group-booking/pay {group_booking_id, invoice_id}`. The server re-verifies with Paymob and rejects underpayment (422) — nothing confirms partially. **The endpoint is idempotent by `invoice_id`: retrying after a network drop is safe and returns success without double effects.**
If `charge == 0` (on_visit, or every guest package-paid): **skip Paymob and skip `/pay` entirely** — the party is already confirmed at create.

### C.3 Free-slot rendering (per specialist)
`free = (working window ∩ user_shifts for that day) − busy slots − (past times if today) − (slots where guest duration overflows the shift end)` — stepped by `agent.time_interval`. Gap hours between split shifts are implicitly blocked because they're outside every shift window. The server re-validates under a row lock at create anyway — a stale screen can only produce a clean structured 409, never a double-book.

### C.4 Error handling (uniform)
Every error carries a flat `message` (always present, human-readable, localized) + `errors`:
```json
{ "success": false, "status": 409, "message": "Guest 2 · Nour: This time slot is no longer available.", "errors": { "MESSAGE": "…", "participant_index": 1 } }
```
- Always render `message`.
- If `errors.participant_index` exists → highlight that guest card (0-based, matches your `participants[]` order).
- On 409 → refresh `agent-slots` for the affected specialists and let the user re-pick; the whole party was rolled back (0 bookings created).

### C.5 Package-paid guests — client-side pre-checks
Offer `package_redemption_id` for a guest only when: same shop, `sessions_remaining > 0`, the guest has **exactly one** service and it's in `eligible_service_ids`, and (if `eligible_specialists` is non-empty) the guest's specialist is listed. The server re-enforces every rule (422 with `participant_index` if violated) — your checks are UX, ours are the law.

---

## D. Questions for you + suggestions (please answer the Qs)

### Questions — we need your input
1. **Paymob order creation:** today you create the Paymob transaction client-side and we verify server-side. Do you want a **backend `payment-intent` endpoint** instead (we create the Paymob order for the exact group total and return the payment key)? It eliminates the whole "app computed the wrong amount" failure class. *(Recommended — say yes and we build it.)*
2. **Zero-cash parties:** all-package (or on_visit) parties are confirmed at create with nothing to charge. Confirm your flow goes straight to the success screen and never calls `/pay` in that case.
3. **Charge amount source:** are you computing the deposit client-side, or reading our fields? If you want, we add a single server-computed **`cash_due_now`** key to the create response so you read one number and never do math. *(Cheap for us — just confirm.)*
4. **Mixed-payment success screen:** for a party with 2 cash guests + 1 package guest, what wording do you show? (Backend gives you: `amount_paid_now` covers cash only, package child flagged.) Send us the copy if you need another field to drive it.
5. **`status: "mixed"`:** how do you want to render a group where children diverge (e.g. one cancelled, rest scheduled)? Options: (a) you handle `"mixed"` in the app; (b) we add a derived `status_summary` object `{scheduled: 2, canceled: 1}`. Pick one.
6. **Bookings list grouping:** today `GET /booking/index` returns **every child** as its own row (each carrying `group_booking_id`). Do you want (a) that, and you group client-side; (b) a `collapse_groups=1` param returning one row per party; or (c) a `group_only=1` / `solo_only=1` filter? Pick — (b)/(c) are small additive params.
7. **Localization:** confirm you send `lang=ar|en` (you already do on `/booking/index?lang=ar`) on ALL group endpoints too — `message`, service names, and guide texts localize off it.
8. **Invoice timing:** the PDF URL exists only after pay settles. Confirm you don't need a pre-payment (proforma) invoice; if you do, that's new scope — tell us.
9. **Cancellation policy display:** `shop.cancellation_policy` is free text today. If you want to render the refund zones dynamically (free-until X hours / partial % / none), we can expose the structured zone config — say the word and we'll spec it together.
10. **Slot stepping edge:** confirm you filter out start times where the guest's total duration overflows the END of a shift (backend rejects them at create, but filtering client-side avoids ugly 409s).

### Suggestions — opt-in, we build on request
- **S1. Paymob webhook settlement for groups:** we already have `/webhook/paymob` for solo. Extending it to settle group parties server-side means a party still confirms even if the app is killed right after payment. Strongly recommended for payment reliability.
- **S2. `payment-intent` endpoint** (same as Q1) — single source of truth for the charge amount.
- **S3. Group payment-options endpoint** (`POST /group-booking/payment-options`): live recomputation of `{modes, deposit_amount, balance_due, total}` for a pending party — useful if the user backgrounds the app and returns later. Mirror of the solo endpoint.
- **S4. Customer push notifications** on group confirm/cancel (specialist-side notify already fires). Tell us which events you want pushed.

**Process:** send answers as a numbered list (Q1→Q10) — anything answered "yes/pick" we'll implement additively and update `GROUP_BOOKING_MOBILE_FLOW.md` in the same commit. Anything unclear in the flow doc, flag the section number and we'll expand it with a worked sample.
