---
feature: Instant and scheduled booking
slug: booking
surface: client
consumer: flutter
version: 2.3.0
status: ready
brd_version: "1.3"
brd_ids: [CUST-012, CUST-013, CUST-014]
business_rules: [BR-02, BR-05, BR-06, BR-08, BR-11, BR-12, BR-13, BR-14, BR-24]
decisions: [D3, DEC-04, DEC-05, DEC-06, DEC-09, DEC-10, TD-12, TD-19, TD-92, TD-93, TD-94, TD-130, TD-131, TD-148]
schema_tables: [coupons, coupon_redemptions, orders, order_people, order_items, order_item_options, order_status_history, payments, refunds, wallets, wallet_transactions]
screens: [customer--booking--03-*, customer--booking--04-*, customer--booking--05-*, customer--booking--05b-*, customer--booking--06-*]
related_docs: [provider-selection.md, orders.md, ../provider/orders.md]
last_updated: 2026-11-09
---

# Instant and scheduled booking — Flutter Integration Guide (client)

> **Base URL:** `{HOST}/api/v1/client` · [Shared API conventions](../API_CONVENTIONS.md).

## Version history

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 2.3.0 | 2026-11-09 | MINOR | Group booking (D2, TD-180): optional `people` on `POST /orders/quote`, `POST /orders` and `GET /providers/{provider}/slots`; new group fields on the order. The 2.2.1 wording that called it built was early; it is built now. | Add the group flow (people → time → review) using §3.5; single-service calls are unchanged. |
| 2.2.1 | 2026-11-08 | PATCH | Limits paragraph rewritten: group booking, packages, payment, cancellation, execution, chat, rating and FCM are built (linked guides). | None. |
| 2.2.0 | 2026-11-03 | MINOR | Wallet is available as a whole-order payment method with two typed errors. | Offer wallet when enabled; handle WALLET_PAYMENTS_DISABLED and WALLET_INSUFFICIENT_BALANCE. |
| 2.1.0 | 2026-11-01 | MINOR | Optional `coupon_code` on quote and create; `coupon` object in the quote; `COUPON_*` errors (TD-131, TD-148). | Add a coupon field to the summary screen; send `coupon_code` to both calls; handle the six `COUPON_*` codes. |
| 2.0.1 | 2026-10-27 | PATCH | Non-production simulator and gateway error guidance. | None |
| 2.0.0 | 2026-10-25 | **Breaking** | `booking_type` is `{value,label}` in the order; human codes `JSH-######`; same key + different body is a 409; per-user throttles; deadlines fire on time. | Read `booking_type.value`; handle 409 `IDEMPOTENCY_KEY_REUSED` and 429. |
| 1.0.0 | 2026-10-25 | Initial | Create a priced order and simulate card/mada payment. | Send one idempotency key per intended order; poll order details. |

## Changelog

### 2.2.2 — 2026-11-09

- **Fixed (docs):** Limits no longer claim group booking is built. `POST /client/orders` and the quote accept one `service_id`, `provider_id`, `address_id`, `option_ids`; `CreateOrderAction` writes exactly one `order_people` row (the customer). Group booking (D2: per-person services, one delivery fee, participant ids, payment and refund semantics) is deferred until its contract is designed and published.
- **Breaking:** No. **App action required:** Keep the group prototype off; do not send participant fields.

### 2.2.1 — 2026-11-08

- **Changed:** Limits paragraph rewritten: group booking, packages, payment, cancellation, execution, chat, rating and FCM are built (linked guides).
- **Breaking:** No. **App action required:** None.

### 2.2.0 — 2026-11-03

- **Added:** Wallet is available as a whole-order payment method with two typed errors.
- **Breaking:** No. **App action required:** Offer wallet when enabled; handle WALLET_PAYMENTS_DISABLED and WALLET_INSUFFICIENT_BALANCE.
- **BRD / decisions:** CUST-015/016, TD-164.

### 2.1.0 — 2026-11-01

- **Added:** optional `coupon_code` on `POST /orders/quote` and `POST /orders`; the quote returns `coupon` (`null` without one) and a non-zero `discount_amount`; `coupon_code` is part of the idempotency body check.
- **Added error codes (422):** `COUPON_INVALID`, `COUPON_EXPIRED`, `COUPON_NOT_APPLICABLE`, `COUPON_FREE_ORDER`, `COUPON_MIN_ORDER_NOT_MET`, `COUPON_USAGE_LIMIT_REACHED`.
- **Changed:** `discount_amount` is no longer always `0.00`; the order total, and the amount `pay` charges, is `services + delivery − discount`.
- **Breaking:** No. **App action required:** add the coupon field and the error handling described in §3.3.
- **BRD / decisions:** CUST-011/014, TD-131, TD-148 (working assumption, awaiting owner confirmation).

### 2.0.1 — 2026-10-27

- **Fixed:** Non-production simulator and gateway error guidance.
- **Breaking:** No. **App action required:** None.


### 2.0.0 — 2026-10-25
- **Changed (breaking):** `booking_type` in the order resource is `{"value","label"}` (was a string). `code` is now a short random `JSH-` + six digits (was a long hash-derived value); apps display it as is.
- **Added:** 409 `IDEMPOTENCY_KEY_REUSED` when the same `Idempotency-Key` arrives with a different body (TD-95); 429 `RATE_LIMITED` on `POST /orders` (6 per minute per user) and `POST /orders/{order}/pay` (10 per minute) (TD-97); each deadline now expires its order on time through its own delayed job and the app is told live (TD-96).
- **Fixed:** a retry with the same key and the same body still returns the same order (now stored for seven days); an instant order blocks the provider from placement until arrival plus service (TD-92).
- **Migration (old → new):** `order.booking_type == 'instant'` → `order.booking_type.value == 'instant'`.
- **App action required:** change the model; generate one key per intended order and never reuse it for a changed order; back off on 429.
- **BRD / decisions:** CUST-012/013/014, TD-92/95/96/97.

### 1.0.0 — 2026-10-25
- **Added:** `POST /orders`, `POST /orders/{order}/pay`; **Changed/Fixed/Removed:** None; **Breaking:** No.
- **App action required:** Keep the same `Idempotency-Key` when retrying a submission, offer payment only while `can_pay`, then poll the order.
- **BRD / decisions:** CUST-012/013/014, D3, BR-12/13/14, TD-92/93/94.

## 1. Business summary

The customer reviews the [quote](provider-selection.md), then submits the same selection. Checkout rechecks provider, choices, slot and price inside a transaction and snapshots Arabic service/option names, prices, address, fee and exact provider commission rate. Scheduled requests wait for provider acceptance before payment; D3 instant orders pay first and wait for provider acceptance. The simulated payment driver is available in non-production environments when enabled and is a **launch blocker** (TD-94).

For QA, keep the existing non-production `APP_ENV` (including `dev`) and set `PAYMENT_SIMULATOR_ENABLED=true`; rebuild cached configuration and restart long-running application workers. This shared simulator also enables provider featured-subscription simulation. No real payment is charged. Both `prod` and `production` remain blocked (TD-130).

## 2. User flow and screens

| Step | Screen | Endpoint | App behavior |
|------|--------|----------|--------------|
| 1 | Time/address instant or scheduled | `GET /providers/{provider}/slots` for scheduled | Refresh when the address or options change. |
| 2 | Summary | `POST /orders/quote` | Show all amounts and the instant ETA or scheduled start. |
| 3 | Submit | `POST /orders` | Hold a unique key through network retries. |
| 4 | Scheduled waiting | `GET /orders/{order}` | `pending_provider` → `awaiting_payment` or `rejected`/`expired`. |
| 5 | Instant waiting | `POST /orders/{order}/pay`, then `GET /orders/{order}` | Paid → `pending_provider` → `confirmed` or refunded on rejection/timeout. Prototype simulation buttons are not API actions. |

## 3. Endpoints

### 3.1 Create order

`POST /api/v1/client/orders` · customer bearer token · JSON · **required `Idempotency-Key` header** (1–100 characters). Reusing the same key with the **same body** returns the same persisted order (kept seven days); the same key with a **different body** is refused with 409 `IDEMPOTENCY_KEY_REUSED`, so generate a new key whenever the customer changes anything. The server locks the customer and provider, and the order `code` is a short random `JSH-` + six digits. Limited to 6 requests per minute per user (429 `RATE_LIMITED`).

| Field | Required | Validation / meaning |
|-------|----------|----------------------|
| `service_id`, `provider_id`, `address_id` | yes | UUIDs; live service, eligible provider, owned address. |
| `booking_type` | yes | `instant` or `scheduled`. |
| `scheduled_at` | scheduled | ISO 8601 instant with offset; full duration must fit the slot. |
| `option_ids` | yes* | UUID array; required active groups satisfied. *Not sent on a group order, see §3.5. |
| `source` | yes | `browse` or `search`. |
| `notes_to_provider` | no | Nullable text, at most 1,000 characters. |
| `coupon_code` | no | Nullable string, max 40; see §3.3. |

Success `201` has the [order details](orders.md) resource. **Real response:** Postman: `client.orders.store` › "201 Instant order created". The same key replays the same `data.id`. Shape error: "422 Missing required booking fields"; commission errors: "422 Create blocked by missing commission rule" and "422 Create blocked by fixed commission rule". Conflict: Postman: `client.orders.store` › "409 Idempotency key reused with another body". Throttle: "429 Too many order requests". Also handle `PROVIDER_UNAVAILABLE`, `INVALID_SERVICE_OPTIONS`, `BOOKING_TIME_UNAVAILABLE`, `DISTANCE_UNAVAILABLE`, `DELIVERY_FEE_UNAVAILABLE` (422). `COMMISSION_RULE_UNAVAILABLE` has a generic customer message; report the failure, do not substitute zero.

### 3.2 Pay order

`POST /api/v1/client/orders/{order}/pay` · customer bearer token · order UUID · JSON.

| Field | Required | Validation / meaning |
|-------|----------|----------------------|
| `method` | yes | `card`, `mada`, or `wallet` (full balance only; TD-164). |
| `simulate_failure` | no | Boolean non-production QA switch; never show in a production app. |

Successful card/mada payment records a unique `(gateway,gateway_reference)` attempt; wallet payment records no gateway reference. Scheduled `awaiting_payment` becomes `confirmed`; instant `awaiting_payment` becomes `pending_provider` with the configured 20-second D3 response window. Repeated pay on a paid service order returns its current state; a paid package purchase rejects a repeat with `INVALID_STATE_TRANSITION`. Limited to 10 requests per minute per user (429, Postman: `client.orders.pay` › "429 Too many payment attempts"). A failed payment persists a failed attempt and `payment_status=failed`, and **does not confirm** the order; retry before `payment_due_at`. **Real responses:** Postman: `client.orders.pay` › "200 Failed simulated payment", "200 Instant payment opens provider response", "200 Scheduled payment confirms order", "422 Missing required booking fields". A missing/foreign/integer order ID returns 404; stale or expired state returns 422 `INVALID_STATE_TRANSITION`; unavailable driver returns HTTP 422 `PAYMENT_GATEWAY_UNAVAILABLE`; show a retry-later message. On HTTP 200 inspect `data.payment_status.value`: `paid` means success and `failed` means the simulated attempt failed. Omit `simulate_failure` or send `false` for success; send `true` for a failed attempt.

### 3.3 Coupons (`coupon_code`)

`POST /api/v1/client/orders/quote` and `POST /api/v1/client/orders` both accept an optional `coupon_code` (case and surrounding spaces do not matter). Send the **same** code to the quote and to create: create re-validates it under a lock and counts the use.

- The discount comes off the **services amount only**; delivery is never discounted and VAT is not involved. Joshen funds it, so provider earnings do not change. `total_amount = services_amount + delivery_fee − discount_amount`, and `POST /orders/{order}/pay` charges that total.
- Percent coupons round half-up to 2 decimals, then are capped by `max_discount`, then by the services amount. `min_order_amount` compares the services amount.
- Coupons apply to service orders only — package purchases and package sessions ignore them.
- A use counts when the order is created. If the order is rejected, expires or is cancelled the use is given back (the order keeps `coupon` and `discount_amount` as history); a completed order keeps it.
- The shared `coupon` object is `{code, discount_amount, funded_by:{value,label}}`, or `null` when no coupon was applied. It appears in the quote and in the [order details](orders.md).

```json
{
  "services_amount": "130.00",
  "delivery_fee": "20.00",
  "discount_amount": "13.07",
  "coupon": { "code": "PCT", "discount_amount": "13.07", "funded_by": { "value": "platform", "label": "Joshen" } },
  "vat_amount": "0.00",
  "total_amount": "136.93"
}
```

Postman: `client.orders.quote` › "200 Quote with a percent coupon"; `client.orders.store` › "201 Order created with a coupon".

| HTTP | `error_code` | When | App behavior |
|------|--------------|------|--------------|
| 422 | `COUPON_INVALID` | Unknown code, switched off, or not usable (provider-funded) | Show `message`; keep the field editable |
| 422 | `COUPON_EXPIRED` | Before `starts_at` or after `ends_at` | Same |
| 422 | `COUPON_NOT_APPLICABLE` | Scope does not cover this service / category / provider | Same |
| 422 | `COUPON_MIN_ORDER_NOT_MET` | Services amount is below the coupon minimum | Same |
| 422 | `COUPON_USAGE_LIMIT_REACHED` | Total or per-customer limit used up | Same; at create, re-quote without the code |
| 422 | `COUPON_FREE_ORDER` | The discount would bring the total to 0 or below | Same |

Postman: `client.orders.quote` › "422 Unknown coupon code", "422 Coupon not started yet", "422 Coupon scoped to another category", "422 Services amount below the coupon minimum", "422 Coupon total usage limit reached", "422 Customer already used the coupon", "422 Coupon would make the order free"; `client.orders.store` › "422 Coupon used up before checkout".

### 3.5 Group booking (D2, TD-180)

One order for the customer and her companions: **one provider, one place and time, one delivery fee**. Screens: people list → time and address → review (`customer--group-booking--04…06`).

| Step | Screen | Endpoint | App behavior |
|------|--------|----------|--------------|
| 1 | People and services | none (local) | The customer is person 1 (`is_customer: true`, her name is locked); each companion is a free-text name with her own services and options. |
| 2 | Time and address | `GET /providers/{provider}/slots` with `people` | Slots fit the **combined** duration; `estimated_duration_min` is returned. |
| 3 | Review | `POST /orders/quote` with `people` | Show `base_amount`, `options_amount`, `delivery_fee` and `total_amount`, then each person from `people[]`. |
| 4 | Submit | `POST /orders` with `people` and the usual `Idempotency-Key` | Same as a single order from here: instant → pay → provider; scheduled → provider → pay. |

`people` replaces `service_id` and `option_ids`; sending both is a 422 on the field.

| Field | Required | Validation / meaning |
|-------|----------|----------------------|
| `people` | group | Array of 2 to `group_booking_max_people` (default 6, customer included). |
| `people[].is_customer` | one person | `true` on exactly one person, the signed-in customer. |
| `people[].name` | companions | Free text, max 100. No account is created. |
| `people[].services` | yes | At least one; the same `service_id` cannot repeat inside one person. |
| `people[].services[].service_id` | yes | Type-1 service UUID the chosen provider offers. |
| `people[].services[].option_ids` | yes | Choice UUIDs, `[]` when none; the option rules apply per service. |
| `coupon_code` | no | **Not offered on a group** (422). |

Rules: `services_amount` is the sum of every person's lines; `delivery_fee` is charged once; `estimated_duration_min` is the sum of every service, so the provider must be free for the whole visit; two people may choose the same service or option. Payment, provider response, cancellation, refund and chat work on the order as a whole; there is no per-person cancel or refund.

Errors: 422 `INVALID_GROUP_BOOKING` (no customer, unnamed companion, a person with no service, a repeated service inside one person), 422 validation on `people` (fewer than 2, more than the cap, mixed with `service_id`, `option_ids` or `coupon_code`), 422 `PROVIDER_UNAVAILABLE` (the provider does not offer one of the services), 422 `INVALID_SERVICE_OPTIONS`, 422 `BOOKING_TIME_UNAVAILABLE` (the combined duration does not fit), 409 `IDEMPOTENCY_KEY_REUSED`, 404 for an address that is not hers.

**Real responses:** Postman: `client.orders.quote` › "200 Group quote, two people", "422 Group without the account owner", "422 Group with a single person", "422 One person asks for a service the provider does not offer", "422 Combined duration does not fit the provider working hours"; `client.orders.store` › "201 Group order created"; `client.providers.slots` › "200 Group slots sized by the combined duration"; the provider's view: `provider.orders.show` › "200 Group order as the provider sees it".

## 4. Statuses and enums

| Status | Scheduled | Instant | App action |
|--------|-----------|---------|------------|
| `pending_provider` | Created; provider decides by `respond_before`. | Reached after successful payment; provider decides by `respond_before`. | Poll; wait for decision. |
| `awaiting_payment` | Provider accepted; pay by `payment_due_at`. | Created; pay before response request is sent. | Show payment only when `can_pay=true`. |
| `confirmed` | Paid after acceptance. | Provider accepted after payment. | Show confirmation. |
| `rejected` | Provider declined. | Provider declined; paid amount is credited to wallet. | Show reason and alternate selection actions. |
| `expired` | Unanswered or unpaid. | Unpaid or paid but unanswered; paid amount is credited to wallet. | Stop payment/decision actions. |

`payment_status`: `unpaid`, `failed`, `paid`, `refunded` are emitted here; the schema also has `pending` and `partially_refunded` for later flows.

## Data model

| Table | Read / write | Notes |
|-------|--------------|-------|
| `orders`, `order_people`, `order_items`, `order_item_options` | write | One person; immutable checkout snapshots and schema total check. |
| `order_status_history` | write by trigger | One row for insert and each status transition, with actor. |
| `payments` | write | One row per simulated attempt; unique gateway reference. |
| `refunds`, `wallet_transactions`, `wallets` | write on paid instant failure | Trigger updates wallet balance; no direct balance mutation. |

## 5. Dart models

Use UUID `String` IDs; money as exact decimal `String`; `DateTime.parse` for offset timestamps. Order `status`, `payment_status` and `booking_type` are `{value,label}` objects. Keep a submission key in local request state until a result has been fetched; do not generate a new key on a retry.

## 6. Business rules enforced by the backend

| Rule | Backend | App response |
|------|---------|--------------|
| BR-02/05/06/08/11 | Rechecks selections, eligibility, fee and slot in the create transaction. | Refresh provider/slot/quote on a conflict. |
| BR-12/D3 | Pay before instant provider response; reject/timeout refunds. | Poll and show refunded result; no prototype simulator buttons. |
| BR-13/14 | Scheduled acceptance precedes payment; failed payment never confirms. | Show retry only while `can_pay`. |
| TD-131/148 | Re-validates the coupon under a row lock at create and counts one use; releases it on rejected / expired / cancelled. | Show the six `COUPON_*` errors; re-quote after a release. |
| TD-93 | Current active percent commission mandatory at quote and create. | Generic temporary unavailable message. |

## 7. Notifications and real-time events

The backend persists `order_request_received` for the provider, `order_accepted_awaiting_payment`, `order_rejected`, `order_expired`, and `order_confirmed` with `data.order_id`. After a notification or a realtime event, call `GET /orders/{order}`. The customer receives `order.updated` over the socket for accepted/rejected/expired/confirmed ([API conventions §9](../API_CONVENTIONS.md), [orders.md §7](orders.md)); poll only while it is disconnected. A production push (FCM) sender is not wired in this slice; the schema inbox event is persisted.

## 8. Localization and UI notes

The API returns localized status labels and ISO timestamps with offsets. Show scheduled starts in Saudi local time. Price values are SAR strings with two decimals. Snapshots retain Arabic names under TD-12.

## 9. Integration checklist

- [ ] Use the same idempotency key on retries; never reuse it for a different intended order.
- [ ] Submit the same selected UUIDs as the quote; refresh on conflict.
- [ ] Drive pay from `can_pay`, show failed/rejected/expired states and poll after payment.
- [ ] Handle each error in §3 and Arabic/English response labels.

## 10. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-CUST-012 | Instant submit and pay | Paid order requests provider; acceptance confirms, rejection/timeout refunds. |
| AC-CUST-013 | Scheduled submit | Provider accepts before customer pays; late/unpaid orders expire. |
| AC-CUST-014 | Review summary | Total equals services + delivery + VAT − discount; VAT is `0.00`, discount is `0.00` unless a coupon was applied. |
| AC-CUST-011/014 | Coupon on quote and create | Percent rounds half-up and respects the cap; fixed is capped at the services amount; delivery untouched; one redemption per order; a replay adds none; reject / expire / cancel release the use, completion keeps it. |

## 11. Open decisions and known limitations

**TD-92 working assumptions:** pending_provider/awaiting_payment/confirmed block the full estimated duration; 15-minute slots, 60-minute lead, 45-minute instant ETA, 30-minute scheduled provider response, 15-minute customer payment. Values proposed by the project owner on 2026-10-25; DEC-05 stays Partly resolved until the client confirms the numbers. DEC-04 capacity/slot holds remain open. TD-93: fixed-amount provider commission is not supported at checkout until the earnings feature adds a snapshot column; checkout rejects it. TD-94: the simulated card/mada driver is a launch blocker; DEC-06 vendor/callbacks are open.

Current limits: order VAT is `0.00` pending DEC-09, and packages and package sessions (D1) ignore coupons. The payment gateway is the staging simulator (TD-94, DEC-06 open). Everything after booking has its own guide: packages in [my-packages](my-packages.md); payment and invoices in [payment](payment.md) and [wallet](wallet.md); cancellation in [cancellation](cancellation.md); execution, transport choice and driver trips in [orders](orders.md); chat in [chat](chat.md); rating in [ratings](ratings.md); push and the realtime events in [notifications](notifications.md) (FCM is wired, TD-141..143).

### Wallet payment (TD-164)

`POST /api/v1/client/orders/{order}/pay` accepts `method: wallet` for a full order or package purchase when enabled and adequately funded. Otherwise it returns 422 `WALLET_PAYMENTS_DISABLED` or `WALLET_INSUFFICIENT_BALANCE`. `wallet_plus_gateway` is rejected. See [wallet](wallet.md). Postman: `client.orders.pay` › "200 Wallet payment".
