# Promotions in the Booking Flow — Backend Delivery Note → Mobile Team

**From:** backend · **Date:** 2026-08-16 · **Baseline:** demo mockup `Navagoo 2.0` v0.28.0 (M6 promotions)
**Status: ✅ LIVE** on `tailwind-poc` — commits `b293e40`, `7e4a8f1`, `2b23d57`, `f7dc071`, `ba2e712`.
<br>(`ba2e712`, 2026-08-18: checkout `promotions[]` rows now also carry `applicable_services` + `shop`, parity with the deal feed.)
**Everything here is ADDITIVE and OPT-IN.** Your current build keeps working unchanged; the new
behaviour only activates when you send the new `resolve_promotions=1` flag / new params.

This note is self-contained: it walks the customer flow **screen by screen** (exactly as the
design mockup renders it), then gives the endpoint contracts with real request/response samples.

---

## 0. What this feature is (30 seconds)

Salons (and Navagoo itself, platform-wide) author **promotions** of two kinds:

| trigger | Meaning | Demo example |
|---|---|---|
| `automatic` | Applies by itself whenever eligible — no code typed. The app shows all eligible ones at checkout and **pre-selects the best (largest discount)**. | "Eid platform deal — 15% off, up to ⃁50" |
| `code` | Customer must type the code at checkout. | `WELCOME25`, `GLOW10` |

Every promotion can carry **conditions**: min order value, day/hour time windows ("Early bird —
Sun–Wed mornings"), service scope, first-time-customer-only, per-customer limit, total usage cap,
validity dates. **One promotion per booking — no stacking.** The server is authoritative for
everything: eligibility, the discount amount, and the final totals. **Never compute a discount
client-side; render the numbers we return.**

---

## 1. The flow, screen by screen (mockup semantics)

### Screen A — Discover → "Deals" tab

A vertical list of live deals: amber tag icon (shop deal) or sparkles icon (platform-wide),
bold label, subtitle = salon name or "Platform-wide · all salons", chevron.

- **Endpoint:** `GET /shops/deals` (bearer optional).
- Render `title` / `title_ar` (Arabic falls back to EN server-side — always safe to display).
- `trigger` tells you whether to hint "auto-applied at checkout" vs show the `code` to copy.
- `shop_id: null` = platform-wide → sparkles icon + "all salons" subtitle; else `shop.shop_name`.
- In the mockup these cards are **display-only** (no tap action). Optional for you: deep-link a
  shop deal to its salon page — your call, not required for parity.

### Screen B — Salon page

Unchanged. `GET /shops/{id}` already carries `active_deals` (same deal shape) if you want a
"deals at this salon" strip. Optional pre-checkout hint: `GET /shops/{id}/applicable-deals`
(§3.3) — e.g. to badge "20% off applies to your selection" while the customer is still picking
services. **Display hint only** — checkout truth is §3.1.

### Screen C — Service selection (salon page)

Unchanged (`/lookups/shop-service`, bundles, etc.). No promo UI on this screen in the mockup.

### Screen D — Specialist + slot

Unchanged (`POST /booking/schedule-booking` / `POST /booking/agent-slots`).
**⚠️ Keep the chosen `schedule_date` + `from_hour` — the checkout quote needs them** (a
time-window deal is judged against the actual appointment slot; without a slot it is
excluded fail-closed).

### Screen E — Checkout (THE promo screen)

Mockup layout, top to bottom:

1. **Booking summary card:** date · time, specialist, service lines with prices, then
   `Subtotal`, then — when a promotion applies — a **green line: promo label + "− ⃁X"**, then
   bold `Total`.
2. **"Promotions & codes" card:**
   - Code input + **Apply** button.
   - On rejection: **amber inline banner** with the specific reason (we return it bilingual).
   - On success: **green "Code {code} applied" chip + Remove**; the code's promo joins the list
     below and becomes the selection.
   - `"Choose a deal — best is selected"` — every applicable promotion as a **single-select
     radio list**, each row: label, subtitle ("15% off, up to ⃁ 50"), **− ⃁X** discount,
     radio, and an **(i)** info button opening a rules popover.
   - The **largest discount carries a `BEST` badge** — the badge stays on the best row even
     when the customer selects a different one.
3. **"Booking for someone else?"** toggle (existing beneficiary flow — unchanged by this note).
4. **"When do you want to pay?"** — Pay online / Deposit N% / Pay on visit. **The deposit is
   computed on the DISCOUNTED total** (server does this; read `payment-options` / our numbers).
5. CTA: **"Pay ⃁TOTAL now"**.

Behaviour rules (all server-enforced, mirror them in UI state):

- Default selection = the **best** applicable promo, applied with **zero customer input**.
- Selecting another row → call the quote again with `promo_id=<that id>` → re-render totals.
- Typing a valid code → it **wins the selection** (customer intent beats "best").
- A **rejected code never blocks** the deals: show the amber reason, keep the best deal applied.
- No applicable promos → show "No promotions apply to this order." and no discount line.

**Endpoint:** `POST /booking/preparing-booking` with `resolve_promotions=1` (§3.1).

### Screen E(i) — the rules popover

Build it from the structured fields on each `promotions[]` row (no extra call):

| Field | Popover line |
|---|---|
| `discount_type`+`discount_value`+`max_discount` | "15% off" / "15% off, up to ⃁ 50" / "⃁ 25 off" |
| `min_order_value` | "Minimum order ⃁ 150" |
| `time_windows` | "Sun/Mon/Tue/Wed, 8:00–12:00" (days: 0=Sunday … 6=Saturday; hours `[from, to)`) |
| `expires_at` | "Valid until 31 Dec 2026" |
| `applicable_services` | "Applies to: {service names}" — `NULL` = all services (now on checkout `promotions[]` rows too, parity with the deal feed) |

### Screen F — Payment / Paymob

Unchanged: `POST /booking/payment-options`, Paymob SDK, `POST /booking/pay`,
`POST /booking/confirm-on-visit`, webhook settlement. All amounts already reflect the discount
because they derive from the booking's stored `total_amount`.

### Screen G — Confirmation

Unchanged (booking id, when, paid-now, due-on-visit). The booking response carries
`promo_code_id` + `discount_value` (VAT-inclusive) if you want a "You saved ⃁X" line.

---

## 2. Create + book — the authoritative path

Your existing 3-step create is unchanged in shape; two additions:

1. **`POST /booking/booking-services`** — send the SAME promo params you quoted with:
   `resolve_promotions=1` + (`promo_id=<id>` for a selected deal **or** `promo_code=<text>`
   for a typed code) + `schedule_date` + `from_hour` (the slot the customer picked). The server
   **re-validates authoritatively**, computes the discount itself, stamps
   `promo_code_id`/`discount_value`/`total_amount`, and records the redemption.
   *(Without the flag: the legacy single-code behaviour, byte-identical.)*

2. **`POST /booking/book`** — no new params. If the booking carries a promo, the server
   re-validates it against the **final** slot. If a time-window deal no longer fits (customer
   changed the slot after the quote), you get:

   ```json
   HTTP 422
   { "success": false, "status": 422, "errors": {
       "MESSAGE": "This offer is not valid at the selected time.",
       "promo_reason": "outside_time_window",
       "promo_message": { "en": "This offer is not valid at the selected time.",
                          "ar": "هذا العرض غير صالح في الوقت المحدد." } } }
   ```

   **Handle it by re-quoting** (§3.1 with the new slot) and re-rendering the checkout — we
   refuse explicitly rather than silently charging a different total than the customer saw.
   A booking is never rejected by its *own* footprint (its own redemption/caps are excluded
   server-side), so quote→create→book with an unchanged slot cannot 422 on the promo.

3. **Abandoned checkouts are safe now:** creating a new booking deletes the customer's stale
   `STATUS_NEW` bookings AND **releases their promo redemptions** — a customer who bailed at
   payment can re-use their one-per-customer code. (Previously the cap was burned forever.)

---

## 3. Contracts

### 3.1 `POST /booking/preparing-booking` — the checkout quote (bearer required)

**New request params (all optional, additive):**

| param | type | notes |
|---|---|---|
| `resolve_promotions` | int | `1` activates everything in this note. Absent/0 = legacy. |
| `promo_id` | int | explicit selection from a previous quote's `promotions[]` |
| `schedule_date` | string `YYYY-MM-DD` | the picked slot's date — **send it** |
| `from_hour` | string `HH:MM` | the picked slot's start — **send it** |
| `promo_code` | string | (existing param) the typed code |

**Response — existing fields unchanged**, plus:

```json
{ "success": true, "status": 200, "data": {
    "amount": 130.43, "vat": 15.65, "total_paid": 120.00,
    "services_ids": [63, 65],
    "discount_value": 30.0,            // the applied promo's discount, VAT-INCLUSIVE
    "invitation_discount": 0,
    "valid_code": 0, "promo_code": null, "promo_code_id": 764,

    "promotions": [                     // ← the "Choose a deal" list, BEST FIRST
      { "id": 764, "trigger": "automatic", "code": null,
        "title_en": "Early bird 20%", "title_ar": "خصم الصباح الباكر 20%",
        "discount_type": "percentage", "discount_value": 20,
        "max_discount": null, "min_order_value": null,
        "time_windows": [ { "days": [0,1,2,3], "from_hour": 8, "to_hour": 12 } ],
        "discount_amount": 30.00,       // VAT-INCLUSIVE — render "− ⃁ 30.00"
        "is_best": true, "is_applied": true,
        "expires_at": "2026-12-31T23:59:59Z", "shop_id": 16,
        "applicable_services": [63, 65],   // NULL = all services (parity with the deal feed)
        "shop": { "shop_id": 16, "shop_name": "Nadia Beauty", "image": "https://…" } },  // NULL for a platform-wide deal
      { "id": 763, "trigger": "automatic", "...": "…",
        "discount_amount": 22.51, "is_best": false, "is_applied": false,
        "applicable_services": null, "shop": null }
    ],
    "applied_promotion_id": 764,        // the one folded into vat/total_paid
    "valid_code_reason": null,          // machine enum when a TYPED code was rejected
    "valid_code_message": null          // {"en": …, "ar": …} — show the amber banner from this
} }
```

Notes:
- `discount_value` (top level) and `promotions[].discount_amount` are **VAT-inclusive display
  numbers**; `amount`/`vat`/`total_paid` keep their existing semantics.
- Selection precedence server-side: `promo_id` → valid typed `promo_code` → best. Whatever was
  applied is `applied_promotion_id` + `is_applied:true` in the list.
- Rejected typed code: `valid_code:1` + `valid_code_reason` + bilingual `valid_code_message`,
  and the best deal is still applied (list + totals reflect that).
- **Additive (2026-08-18):** each `promotions[]` row now also carries `applicable_services`
  (int[] of shop_service ids, or `NULL` = all services) and `shop` (`{shop_id, shop_name,
  image}`, or `NULL` for a platform-wide deal) — the same shape the `/shops/deals` feed
  already returns. Previously the checkout list exposed only the bare `shop_id` and omitted
  the service scope, so "Applies to …" and the owning-shop chip could not render at checkout.
  Purely additive; existing keys unchanged.

### 3.2 `POST /booking/booking-services` — create (bearer required)

Same params as 3.1 (plus your existing ones). Response = the booking resource, promo stamped.
Then `POST /booking/book` exactly as today — just handle the promo 422 (§2.2).

### 3.3 `GET /shops/{id}/applicable-deals` — pre-checkout hint (bearer optional)

**New query params:** `schedule_date`, `from_hour` (judge time windows), and the existing
`service_ids=63,65` now also powers `discount_amount`.
**New response keys per deal:** `discount_amount` (VAT-incl or null), `is_best` (top applicable
row), plus the new deal fields below. List is ranked applicable-first, largest-saving-first.
Unauthenticated callers: per-customer checks are skipped (documented hint-only behaviour).

### 3.4 `GET /shops/deals` + `shop.active_deals` — new fields on every deal object

`trigger` (`"automatic"`|`"code"`), `title_ar`, `description_ar`, `min_order_value`,
`max_discount`, `time_windows` — all nullable, everything you render today is untouched.

---

## 4. Rejection reasons (the amber banner)

`valid_code_reason` / `promo_reason` values — `valid_code_message` / `promo_message` carry the
ready-made bilingual strings (shown here EN; Arabic included in the payload):

| reason | message (en) |
|---|---|
| `not_found` | No promo code matches that. |
| `inactive` | This code is no longer active. |
| `wrong_shop` | This code is not valid at this salon. |
| `not_started` | This code is not active yet. |
| `expired` | This code has expired. |
| `total_cap_reached` | This code has been fully redeemed. |
| `per_customer_cap_reached` | You have already used this offer the maximum number of times. |
| `first_time_only` | This code is for first-time customers only. |
| `service_not_in_scope` | This code does not apply to the services in this order. |
| `min_order_not_met` | Order must be at least {min}. *(min substituted server-side)* |
| `outside_time_window` | This offer is not valid at the selected time. |
| `not_applicable` (fallback) | This code does not apply to this booking. |

---

## 5. Contract warnings (read these twice)

1. **Always send `schedule_date` + `from_hour` with the quote and the create.** A time-window
   deal with no slot context is excluded/rejected **fail-closed** — that is deliberate (we never
   guess a discount).
2. **Never total up client-side.** Quote → render our numbers → create → render our numbers.
   The deposit split, VAT, and "paid now" all derive from the discounted total server-side.
3. **One promotion per booking.** The single-radio UI is the contract, not just a style.
4. **Platform-wide deals are now really redeemable** (`shop_id: null`). This was broken before
   this delivery (advertised in the feed, always rejected at checkout as `wrong_shop`). If the
   same code text exists both platform-wide and at the booking's salon, **the salon's own code
   wins** — deterministic, no error.
5. **`promo_id` that no longer applies is not an error** at quote time — the server just applies
   the best instead; detect via `applied_promotion_id` ≠ your requested id and re-render.
   At `/booking/book` time a dead promo IS an error (422) — re-quote.
6. Legacy `valid_code` semantics unchanged: `0` accepted-or-none, `1` rejected.

---

## 6. Not built yet (deliberately) — say if you need any of these

- **Customer-type conditions beyond first-time**: `returning`, `navagoo_sourced`,
  `first_service` (demo models them; our schema/validator currently enforce
  `first_time_customer_only` = the demo's `new`). Additive when needed.
- **Category-level scope** (`categoryIds`) — we scope by explicit service ids only.
- **Stacking** (`stackable`) — demo v1 default is also "one per booking", so parity holds.
- **Deal-card tap-through** on the Deals tab — mockup has none; deep-linking is your option.

**Open commercial question (backend-side, flagged to the owner, does not block you):** the
platform marketing fee is currently computed on the booking total **net of the promo discount**
(Navagoo shares the promo's cost). Whether the fee basis should be gross-of-promo is part of
the pending OI-FIN-11 ruling; either way the customer-facing numbers in this note are final.

---

## 7. Questions for you

- **Q1:** Do you want the Deals-tab cards to deep-link to the salon page? (We can add a
  `shop` deep-link payload — it is already derivable from `shop_id`.)
- **Q2:** Do you want a "My used promos" surface (read API over redemption history)? Nothing
  exists today; additive if wanted.
- **Q3:** For the checkout list order we return best-first. If you prefer a different stable
  ordering (e.g. automatic-then-code), say so — trivial server-side.
