# Group Booking — Mobile App Flow (Step-by-Step API Contract)

**Audience:** mobile (Flutter) team · **Base host:** `api.navagoo.localhost` (dev) · **Auth:** `Authorization: Bearer <customer token>` on every call below.
**Status:** everything in this document is **live on `tailwind-poc`** as of 2026-08-05 (commits `47ab9bf`, `521f7ca`, `139ee21`, `0036c0b`). All new request fields are **optional** — omitting them gives the old behavior.

The flow, in the exact order the app drives it:

> pick a specialist → get that specialist's services → see when the specialist is busy →
> assign people/services/times per specialist → create the group booking with a payment
> method (full / partial / at venue / package) → pay → confirm the payment.

A note on **errors** first, since every step shares it: every API error now carries a flat
`message` string you can always read, plus the legacy `errors` payload:

```json
{ "success": false, "status": 422, "message": "…human readable…", "errors": { "MESSAGE": "…", "participant_index": 1 } }
```

---

## Step 0 — Shop context (payment methods, group cap, packages)

**`GET /shops/{id}`**

Besides the existing shop profile, the response now includes everything the group screen
needs up front:

```json
{
  "success": true, "status": 200,
  "data": {
    "id": 15, "title": "Beauty Center",
    "max_group_size": 10,
    "payment": {
      "allowed_payment_methods": ["on_visit", "deposit", "online"],
      "deposit_percentage": 30
    },
    "subscription_packages": [ { "id": 2, "name": "Golden Hair Care", "price": 300, "per_session_price": 60, "sessions": 5, "validity_days": 90, "services": [ { "id": 101, "name": "Hair Cut" } ] } ],
    "active_deals": [ "… Deal objects (id, shop_id, shop_name, promo_code, discount_type, discount_value …) …" ]
  }
}
```

- `payment.allowed_payment_methods` — the ONLY methods you may offer at checkout.
  Vocabulary (same everywhere): **`online` = full**, **`deposit` = partial**, **`on_visit` = pay at venue**.
  A shop with no configuration is **online-only**. Branches inherit the parent shop's
  config automatically when they have none of their own.
- `max_group_size` — disable "Add participant" beyond this.

---

## Step 1 — Pick a specialist

**`GET /agent/agents?shop_id={shopId}`** (paginated; optional `top_rated=1`)

Returns the shop's active specialists (abridged — main keys):

```json
{
  "success": true, "status": 200,
  "data": [
    {
      "id": 4,
      "name": "Sara",
      "image": "https://…/avatar.jpg",
      "shop_name": "Beauty Center",
      "rate": { "rate_average": 4.8, "total_rates": 122 },
      "from_hours": "10:00", "to_hours": "22:00",
      "limited_schedule_days": 30,
      "bio": "…"
    }
  ]
}
```

---

## Step 2 — Services THIS specialist can perform

**`GET /agent/agents/agent-services?agent_id={agentId}`**

Sourced from the authoritative `user_shop_service` capability table:

```json
{
  "success": true, "status": 200,
  "data": [
    {
      "id": 101,
      "name": "Hair Cut",
      "image": "https://…",
      "price_before_discount": 120,
      "price": 100,
      "sample": false,
      "period": 30,
      "service_id": 7
    }
  ]
}
```

- `id` = the **shop_service id** — this is what you send in `service_ids` everywhere below.
- `period` = minutes; sum the selected services to know the guest's duration.

*(Shortcut: the specialist object inside Step 3's response also carries `service_ids: [101, 102, …]` inline, if you want to pre-filter without this call.)*

---

## Step 3 — When is this specialist busy?

**`POST /booking/agent-slots`**

```json
{ "agent_id": 4, "schedule_date": "2026-08-10" }
```

Response — the specialist + their **BOOKED (busy) intervals** for that day:

```json
{
  "success": true, "status": 200,
  "data": {
    "agent": {
      "id": 4, "name": "Sara", "image": "…",
      "from_hours": "10:00", "to_hours": "22:00",
      "user_shifts": [ "…grouped working shifts…" ],
      "time_interval": 30,
      "service_ids": [101, 102, 105]
    },
    "slots": [
      { "booking_date": "2026-08-10", "from_hour": "10:00", "to_hour": "10:30" },
      { "booking_date": "2026-08-10", "from_hour": "13:00", "to_hour": "14:00" }
    ]
  }
}
```

The app renders free times = working window (`from_hours`→`to_hours` / `user_shifts`,
stepped by `time_interval`) **minus** the `slots` intervals **minus** the guest's own
duration overflow. Repeat per specialist involved in the party.

*(The server re-validates every slot at create time under a row lock — a stale screen can
never double-book; you'll get a structured 409 instead, see Step 5.)*

---

## Step 4 (optional) — Paying a guest with a pre-paid package

**`GET /my-packages`**

Now returns everything needed to offer "pay with package" per guest:

```json
{
  "success": true, "status": 200,
  "data": [
    {
      "id": 12,
      "shop_id": 15,
      "subscription_package_id": 2,
      "package_name": "Golden Hair Care",
      "package_name_ar": "باقة العناية بالشعر الذهبية",
      "sessions_total": 5, "sessions_remaining": 4,
      "price": 300, "per_session_price": 60,
      "expiry_date": "2026-10-25", "status": "active",
      "eligible_service_ids": [101, 102],
      "services": [ { "id": 101, "name": "Hair Cut" }, { "id": 102, "name": "Hair Styling" } ],
      "eligible_specialists": [ { "id": 4, "name": "Sara" } ],
      "shop": { "id": 15, "name": "Beauty Center", "image": "…" }
    }
  ]
}
```

Offer entitlement `id` (here `12`) as `package_redemption_id` for a guest **only if**: same
`shop_id`, `sessions_remaining > 0`, the guest's single service ∈ `eligible_service_ids`,
and (when `eligible_specialists` is non-empty) the guest's specialist is on that list.
The server re-enforces all of this anyway.

---

## Step 5 — Create the group booking (people per specialist + payment method)

**`POST /group-booking/create`**

```json
{
  "shop_id": 15,
  "appointment_date": "2026-08-10 10:00",
  "payment_timing": "deposit",
  "participants": [
    {
      "name": "Mohammed",
      "is_organiser": true,
      "specialist_id": 4,
      "service_ids": [101, 102],
      "time_slot": "10:00"
    },
    {
      "name": "Guest 2",
      "specialist_id": 5,
      "service_ids": [103],
      "time_slot": "10:30"
    },
    {
      "name": "Guest 3",
      "specialist_id": 4,
      "service_ids": [101],
      "time_slot": "11:30",
      "package_redemption_id": 12
    }
  ]
}
```

Field rules:

| Field | Rule |
|---|---|
| `appointment_date` | required — the day + the **default** start for guests with no `time_slot`. |
| `payment_timing` | required — `online` (full) \| `deposit` (partial) \| `on_visit`. Must be in the shop's `allowed_payment_methods`. Applies to the **cash** guests. |
| `time_slot` | optional `"HH:MM"` per guest, same day. Omit → shared start. |
| `specialist_id` | optional — omit to auto-assign a capable, free specialist. **One specialist may serve several guests at non-overlapping times** (Guest 3 with Sara at 11:30 while Mohammed had her at 10:00). Overlapping windows on the same specialist → 422. |
| `service_ids` | required, ≥1, from Step 2 (this shop's services only). |
| `package_redemption_id` | optional — entitlement id from Step 4. That guest must have **exactly one** service, included in the package. |
| `is_organiser` | optional — defaults to participant 0. |

**Success — `201`** (the same shape `GET /group-booking/view/{group_booking_id}` returns —
including the details/success-screen aggregates from `BACKEND_GROUP_BOOKING_SPECIFICATIONS` §1/§3):

```json
{
  "success": true, "status": 201,
  "data": {
    "group_booking_id": "GRP-2608-4F7K2",
    "appointment_date": "2026-08-10 10:00:00",
    "party_size": 3,
    "name": "Mohammed Ali",
    "value": 350.0,
    "amount_paid_now": 0.0,
    "cash_due_now": 105.0,
    "outstanding": 250.0,
    "deposit_percentage": 30,
    "payment_mode": "deposit",
    "status_summary": { "selected_not_paid": 2, "scheduled": 1 },
    "duration_min": 60,
    "status": 1,
    "shop": {
      "id": 15,
      "name": "Beauty Center",
      "address": "King Fahd Road, Riyadh",
      "lat": "24.7136",
      "lng": "46.6753",
      "cancellation_policy": "Free cancellation up to 24h prior"
    },
    "invoice": null,
    "children": [
      {
        "id": 9001, "guest_label": "Mohammed", "is_group_organiser": true,
        "specialist_id": 4, "specialist_name": "Sara",
        "service_ids": [101, 102],
        "services": [
          { "id": 101, "name": "Hair Cut", "price": 100.0, "period": 30 },
          { "id": 102, "name": "Hair Styling", "price": 100.0, "period": 30 }
        ],
        "booking_date": "2026-08-10 10:00:00", "from_hour": "10:00", "to_hour": "11:00",
        "status": 1, "payment_mode": "deposit",
        "total_amount": 200.0, "amount_collected": 0.0, "balance_due": 200.0,
        "refund_value": null, "package_entitlement_id": null
      },
      {
        "id": 9002, "guest_label": "Guest 2", "is_group_organiser": false,
        "specialist_id": 5, "specialist_name": "Nour",
        "service_ids": [103],
        "services": [ { "id": 103, "name": "Facial", "price": 150.0, "period": 30 } ],
        "booking_date": "2026-08-10 10:30:00", "from_hour": "10:30", "to_hour": "11:00",
        "status": 1, "payment_mode": "deposit",
        "total_amount": 150.0, "amount_collected": 0.0, "balance_due": 150.0,
        "refund_value": null, "package_entitlement_id": null
      },
      {
        "id": 9003, "guest_label": "Guest 3", "is_group_organiser": false,
        "specialist_id": 4, "specialist_name": "Sara",
        "service_ids": [101],
        "services": [ { "id": 101, "name": "Hair Cut", "price": 100.0, "period": 30 } ],
        "booking_date": "2026-08-10 11:30:00", "from_hour": "11:30", "to_hour": "12:00",
        "status": 2, "payment_mode": "package",
        "total_amount": 100.0, "amount_collected": 0.0, "balance_due": 0.0,
        "refund_value": null, "package_entitlement_id": 12
      }
    ]
  }
}
```

Group-level aggregates (success screen): `amount_paid_now` = Σ children `amount_collected`
(0 until pay confirms; equals the deposit/full charge afterwards) · `payment_mode` is the
CASH mode chosen at create (`"package"` only when EVERY guest redeemed a session) ·
`deposit_percentage` comes from the shop's payment settings (null when deposit is off) ·
`invoice` = the group invoice PDF URL, populated after `/group-booking/pay` settles ·
`shop.cancellation_policy` = the shop's cancellation terms. **NOTE: `payment_mode` values
are lowercase** (`online` | `deposit` | `on_visit` | `package`) — an earlier draft of this
doc showed them uppercase; lowercase is what the API actually returns.

How to read the money (this is where the payment methods differ):

- **Package guest** (`payment_mode: "PACKAGE"`): confirmed immediately (`status` =
  SCHEDULED), `balance_due` 0, session already decremented. **Not part of the online
  charge.**
- **Cash guests with `online`/`deposit`**: left pending-payment (`status` =
  SELECTED_NOT_PAID) until Step 7 confirms.
- **Cash guests with `on_visit`**: confirmed immediately; whole balance due at the shop —
  Steps 6–7 are skipped entirely.

**Amount the app must charge in Step 6: read `cash_due_now`.** It is server-computed
(mobile answers Q3) and equals, for reference:

```
cash_children = children where status == SELECTED_NOT_PAID
online  → Σ cash_children.total_amount
deposit → Σ round(cash_children.total_amount × deposit_percentage / 100, 2)   // per child, then sum
```
(Example above, deposit 30%: (200 + 150) × 0.30 = **105.00 SAR**; the remaining 245.00 is
each child's balance at the venue.)

**Errors — structured, guest-addressable:**

```json
// 422 — pre-validation names the failing guest card:
{ "success": false, "status": 422, "message": "Participant 2: this specialist is already assigned to another participant.",
  "errors": { "MESSAGE": "…", "participant_index": 1 } }

// 409 — slot lost in the race (someone booked it first). Whole party rolled back:
{ "success": false, "status": 409, "message": "Guest 2 · Nour: This time slot is no longer available.",
  "errors": { "MESSAGE": "…", "participant_index": 1 } }
```

`participant_index` is 0-based and matches your `participants[]` order — highlight that
guest's card. **A create failure never books anyone** (all-or-nothing).

---

## Step 6 — Pay (client side)

For `online`/`deposit` parties: charge the **exact amount computed in Step 5** through the
Paymob SDK / iframe, exactly like a solo booking. One transaction covers the whole party.
Keep the resulting Paymob transaction id — it is the `invoice_id` for Step 7.

*(The backend does not mint the Paymob key for you — same as the existing solo flow. It
verifies server-side in Step 7 via Paymob's inquiry API, so a forged/short payment can't
confirm anything.)*

---

## Step 7 — Confirm the payment

**`POST /group-booking/pay`**

```json
{ "group_booking_id": "GRP-2608-4F7K2", "invoice_id": "318844221", "payment_mode": "deposit" }
```

- `payment_mode` optional — defaults to the mode chosen at create.
- Server verifies the transaction with Paymob (`Paid` + amount covers the group cash
  total), then **atomically**: confirms every pending child (→ SCHEDULED), stamps
  `amount_collected`/`balance_due` per child, records the Payment, and books the ledger
  charges. **Idempotent** — replaying the same `invoice_id` returns success without double
  side-effects.

**Success** → the same group shape as Step 5, now:

```json
"children": [ { "id": 9001, "status": 2, "payment_mode": "DEPOSIT", "amount_collected": 60.0, "balance_due": 140.0 }, "…" ]
```

**Errors:**

| HTTP | Meaning | What the app does |
|---|---|---|
| `404` | Paymob says not `Paid` | keep the party pending; let the user retry payment |
| `422` | paid amount doesn't cover the group total | contact support / retry with correct amount — nothing confirmed |
| `409` | "no bookings awaiting payment" | party was on_visit / all-package / already settled — treat as done |

---

## After booking — the rest of the lifecycle

| Need | Endpoint |
|---|---|
| Re-fetch the party | `GET /group-booking/view/{group_booking_id}` |
| Bookings list, one row per party | `GET /booking/index?collapse_groups=1` (organiser row represents the party; omit → row per guest) |
| Cancel ONE guest | `POST /group-booking/cancel-participant` `{ booking_id }` — **blocked on the last remaining guest**; when only 1 guest is left, call cancel-group instead |
| Cancel the WHOLE party | `POST /group-booking/cancel-group` `{ group_booking_id }` — per-child refund policy (zones) applies through the ledger |
| Solo-booking payment options screen | `POST /booking/payment-options` `{ booking_id }` → `{ modes, deposit_percentage, deposit_amount, balance_due, total_amount, currency }` |

---

## Quick recap (one line per step)

1. `GET /shops/{id}` → allowed payment methods + deposit % + `max_group_size` + packages.
2. `GET /agent/agents?shop_id=` → pick the specialist.
3. `GET /agent/agents/agent-services?agent_id=` → that specialist's services (+ prices/durations).
4. `POST /booking/agent-slots` `{agent_id, schedule_date}` → busy intervals → render free times.
5. (optional) `GET /my-packages` → offer per-guest package payment.
6. `POST /group-booking/create` → people × specialist × services × time_slot × payment method (+ per-guest package) → party + per-child amounts, or a `participant_index`-addressed error.
7. Charge the computed cash total via Paymob (skip for on_visit / all-package).
8. `POST /group-booking/pay` `{group_booking_id, invoice_id}` → server-verified, atomic, idempotent confirmation.
