# Backend Verification of the Mobile-Team API Audit

**Reviewed doc:** `NVG_FINAL_REVIEW.md` (mobile team, dated Aug 04, 2026)
**Verified by:** backend, against the **actual `api/` tier code** (not handover docs) on `2026-08-04`.
**Method:** every claim was checked against the live controller / resource / model source in this repo (`api/controllers`, `api/resources`, `common/models`, `common/components`) with `file:line` evidence.

> **Read this first.** The mobile audit is largely written against **stale handover docs**. Verified against the code that is actually deployed, a large share of the "CRITICAL missing" items are **already implemented** — several were shipped under NVG‑BEA‑008/009/011/001 after those handover docs were written. This document says, per claim, whether it is **already live**, a **genuine gap**, or **already provided but on a different endpoint / under a different key name**.

---

## Executive verdict (23 claims)

| Bucket | Count | Meaning |
|---|---:|---|
| ✅ **Already provided** (stale doc) | 8 | Backend already returns it. **Mobile: update your integration.** No backend work. |
| 🔴 **Genuine gap** | 8 | Really missing. Split below into *additive-safe* vs *needs-agreement*. |
| 🟡 **Partial** | 7 | Data exists, but on a **different endpoint** or under a **different key name**, or needs a small additive extension. |

**Bottom line:** nothing here shows the backend as "broken." The core booking/deal/package/payment engines are live. The real backend to‑do list is **8 items**, of which only **4 change the shared contract or schema** (and per `CLAUDE.md` those must be agreed with mobile before we build them). Everything else is either already live (mobile points at the wrong endpoint/key) or a cheap additive field.

---

## Legend

- ✅ **ALREADY PROVIDED** — the key/behavior exists; mobile is reading a stale spec.
- 🔴 **CONFIRMED GAP** — genuinely missing in the backend.
- 🟡 **PARTIAL** — exists but not where/as the audit assumes (different endpoint, different key, or needs a small additive field).
- **Owner** — who acts: **BE** (backend builds), **MOB** (mobile re-points integration), **BOTH** (agree naming/contract first).

---

## 1. Group Booking (BEA‑011)

Engine: `api/controllers/GroupBookingController.php`, `common/components/GroupBookingService.php`, `api/resources/ShopsResource.php`.

| # | Claim | Verdict | Evidence | Owner |
|---|---|---|---|---|
| 1 | Per‑participant `time_slot` in `participants[]` | 🔴 **GAP** | `actionCreate` reads ONE shared `appointment_date` (`GroupBookingController.php:97-106`); per‑participant fields read are only `service_ids`/`specialist_id`/`is_organiser`/`name` (`:125-172`). Shared start is a **deliberate rule (BR‑G06)** — the service stamps every child with the same `booking_date`/`from_hour` (`GroupBookingService.php:318-320`). | **BOTH** — contract + design change |
| 2 | `GET /shops/:id` omits `max_group_size` | ✅ **ALREADY PROVIDED** | `ShopsResource.php:118-120` returns `max_group_size` (shop override → commercial_config → default 10). Shipped in BEA‑011 W1. | **MOB** |
| 3 | `participant_index` + `MESSAGE` in 422 | 🟡 **PARTIAL** | Pre‑validation already returns `{MESSAGE, participant_index}` (`GroupBookingController.php:644-650`). But the **slot‑conflict** from the locked placement check returns HTTP **409, `MESSAGE` only, no index** (`:203-209`). | **BE** (cheap) |
| 4 | cancel‑participant blocked on last guest → must call cancel‑group | ✅ **ALREADY PROVIDED / by design** | The block is intentional and the error names the fallback: *"…Cancel the whole party instead."* (`:321-331`). `cancel-group` exists (`actionCancelGroup :268-281`, route `_urlManager.php:224`). This IS the supported flow. | **MOB** |
| 5 | `specialist.service_ids` linkage | 🟡 **PARTIAL** | Linkage is served (correctly from `user_shop_service`) via `GET agent/agents/agent-services?agent_id=` (`AgentsController.php:75-93`), but not inline on the specialist object. | **MOB** (or BE adds inline field) |
| 6 | Parallel specialist booking / busy handling | ✅ **ALREADY PROVIDED / by design** | Different specialists run in parallel at the shared start (`GroupBookingService.php:416`); same specialist twice in one party is intentionally blocked (`:184-190`); an already‑busy specialist is blocked by `checkPlacement` (`:272-292`). | **MOB** |
| 7 | Assumes online Paymob only (wants Full / Partial / Pay‑at‑venue) | ✅ **ALREADY PROVIDED** | Create accepts `payment_timing ∈ {online, deposit, on_visit}`, each gated by `ShopPaymentSettings::getEnabledModes()` (`GroupBookingController.php:110-117, 658-675`). Full=`online`, Partial=`deposit`, Pay‑at‑venue=`on_visit`. | **MOB** |
| 8 | `package_redemption_id` per participant | 🔴 **GAP** | No package redemption anywhere in the group flow (`actionCreate` never reads it; `actionPay` settles cash via Paymob only). The solo package path (`/subscription-package/redeem`) is not wired into group create. | **BOTH** — contract + data‑model |

> **Scope note (BEA‑011 BRD):** *split payment · group pricing/discounts · multi‑day groups* are explicitly **out of scope**. Group‑pay is designed as **one Paymob transaction** for the whole party. So claim #1 (per‑guest slots) and #8 (per‑guest package redemption) are not "bugs" — they are **new scope** that changes the agreed group contract, and must be decided as product items.

---

## 2. Service Bundles / Subscription Packages (BEA‑008)

Engine: `api/controllers/SubscriptionPackageController.php`, models `SubscriptionPackage` + `PackageEntitlement`. **Redemption is a dedicated flow, not a booking flag.**

| # | Claim | Verdict | Evidence | Owner |
|---|---|---|---|---|
| 1 | `GET /my-packages` omits `services` / `eligible_service_ids` | 🔴 **GAP** | `shapeMyPackage()` (`:565-590`) returns no services. Data exists (`SubscriptionPackage::serviceIds()` L107) and the shop endpoint already returns them. Additive. | **BE** (additive) |
| 2 | `GET /my-packages` omits `price` / `price_per_session` / `currency` | 🔴 **GAP** (partly product) | Not in `shapeMyPackage()`. `price`/`per_session_price` exist on the purchase + redeemable responses (`:541-561`, `:245-254`). **`currency` is emitted nowhere** — platform is single‑currency (SAR); adding it is a product decision. Field is `per_session_price`, not `price_per_session`. | **BE** (price/per_session, additive) |
| 3 | `GET /my-packages` omits `eligible_specialists` | 🔴 **GAP** | Not in the payload; enforced server‑side on redeem (`SubscriptionPackage::specialistIds()` L120, `actionRedeem :360-364`). Redemption is safe without it (server 422s an ineligible specialist). Additive if the app wants client‑side pre‑filtering. | **BE** (additive, optional) |
| 4 | shop pkg list lacks `terms_and_conditions` / `expiry_days` | 🟡 **PARTIAL** | `expiry_days` **is provided** as **`validity_days`** (`subscriptionPackagesForShop() :396-409`) — a **naming mismatch**, not missing. `terms_and_conditions` is **truly absent** (no such column; only `description` + shop‑level `shop.terms_conditions`). | **BOTH** (rename) / **BE** (T&C if needed) |
| 5 | Checkout redemption via `package_redemption_id`/`use_package_session` on `/booking/preparing-booking` or `/book` | ✅ **ALREADY PROVIDED (different endpoint)** | Those params don't exist. Real contract = **`GET /subscription-package/redeemable?shop_id=&service_id=`** then **`POST /subscription-package/redeem`** (`actionRedeem :282-515`) — one `FOR UPDATE` txn: validates ownership/service/specialist/slot, creates a `PACKAGE` booking with `package_entitlement_id`, decrements the session. | **MOB** |

> ⚠️ **Stale‑doc trap:** the `book-package` / `preparing-booking-package` / `book-package-services` routes (`BookingController.php:828-971`) are the **legacy `Package`/`UserPackage`** system (they set `booking->package_id`, not `package_entitlement_id`) — **unrelated** to BEA‑008 subscription packages. If the mobile handover points at those for redemption, that is the source of the confusion.

---

## 3. Deals & Promotions (BEA‑009)

All three deal surfaces (`GET /shops/deals`, `GET /shops/:id/applicable-deals`, shop‑view `active_deals`) serialize through **one** formatter: `api/resources/DealResource.php`.

| # | Claim | Verdict | Evidence | Owner |
|---|---|---|---|---|
| 1 | Deals omit `id`, `shop_id`, `shop_name` | ✅ **ALREADY PROVIDED** | `DealResource.php:22` `id`, `:70` `shop_id`, `:73` `shop_name`, plus nested `shop{shop_id,shop_name,image}` (`:85-94`). Home→ShopProfile nav has everything. | **MOB** |
| 2 | `promo_code` vs `code` inconsistency | ✅ **ALREADY PROVIDED** | Deal objects return **both** `code` and `promo_code` (`:26-30`). Checkout input reads exactly `promo_code` (`BookingForm.php:44,69`). The alias was added to kill this mismatch. | **MOB** |
| 3 | `discount_type` / `discount_value` missing | ✅ **ALREADY PROVIDED** | `discount_type` → `'percentage'`\|`'fixed'` (`:31-33`), `discount_value` → float (`:34-36`), plus a ready human label (`title`/`description`, e.g. `"20% OFF"`). | **MOB** |
| 4 | `preparing-booking` lacks full deal breakdown | 🟡 **PARTIAL** | `PreparingBookingResource.php:10-29` returns `amount`, `vat`, `total_paid`, `discount_value` (promo discount incl VAT), `invitation_discount`. **Missing:** a `valid_code` flag (client can't tell if a code was accepted vs silently ignored — `BookingForm.php:271` computes it but `fields()` omits it) + applied‑promo identity. Additive. | **BE** (additive) |
| 5 | Promo removal/reapplication + auto best/default promo | 🟡 **PARTIAL** | Apply/remove/reapply already work — endpoint is stateless: pass `promo_code` → applied; omit → 0; re‑post → reapplied. **Auto‑select best/default is genuinely absent** (`applicable-deals` lists candidates with an `applicable` flag but never auto‑applies one into the total). | **BE** (only if auto‑best is required) |

---

## 4. Customer Payment Options (NVG‑BEA‑001) + Cross‑Flow

| # | Claim | Verdict | Evidence | Owner |
|---|---|---|---|---|
| 1 | `GET /shops/:id` doesn't expose accepted payment methods | 🔴 **GAP (from that endpoint)** | `ShopsResource::fields()` (`:13-123`) has zero payment keys; `actionView` merges only deals + packages (`:153-160`). Config **is** exposed, but only via **`POST /booking/payment-options`** (auth + `booking_id`). No shop‑level `allowed_payment_methods`. | **BE** (additive block) |
| 2 | Per‑branch payment config / inheritance | 🔴 **GAP** | `ShopPaymentSettings` is keyed **`shop_id` only** (`common/models/ShopPaymentSettings.php:67`); `branches` has `parent_shop_id`/`inherit_parent` but **no payment columns**. Prices/specialist‑capability are shop‑scoped. | **BOTH** — schema change |
| 3 | `preparing-booking` deposit breakdown (`deposit_amount`/`remaining_amount_due_at_venue`/`deposit_percentage`) | 🟡 **PARTIAL** | Not in `PreparingBookingResource`. **All present on `POST /booking/payment-options`** (`BookingController.php:749-757`): `deposit_amount` ✓, `deposit_percentage` ✓, `balance_due` (= mobile's `remaining_amount_due_at_venue`), `modes`, `total_amount`, `currency`. | **MOB** (or BE folds in / renames `balance_due`) |
| 4 | Error shapes vary (`{errors:{MESSAGE}}` vs `{message}` vs `{errors:[..]}`) | 🔴 **GAP — real, highest‑value fix** | `ResponseHelper::sendFailedResponse()` always wraps `{success,status,errors}`, but `errors` is **polymorphic** (`{MESSAGE:..}` / `{field:[..]}` / `["str"]`), **and** framework 401/404/500 bypass the helper entirely (`web.php:14-16`) → `{name,message,code,...}` (the `{"message":..}` mobile saw). Also `sendFailedResponse($model->errors)` **defaults to 401** even for validation errors. | **BOTH** — standardize |
| 5 | Specialist→service mapping (`services:[]` per specialist) | 🟡 **PARTIAL** | Not inline on the specialists listing, but served correctly (from `user_shop_service`) at **`GET /agent/agents/agent-services?agent_id=`** (`AgentsController.php:75-93`). | **MOB** (or BE embeds to kill N+1) |

**Payment vocabulary mismatch (agree once):** backend `modes` = `["on_visit","deposit","online"]`; mobile expects `["full","partial","pay_at_venue"]`. Same three concepts, different words. A shop with no settings row = **online‑only** by default (`BookingController.php:810-818`).

---

## Consolidated action list

### A. Real backend work — additive & safe (no contract break; can build now)
1. **`GET /my-packages`**: add `services`/`service_ids`, `price`, `per_session_price`, and (optional) `eligible_specialists`. *(BEA‑008, Pkg #1‑3 — data already exists server‑side.)*
2. **`preparing-booking`**: add `valid_code` flag (+ applied‑promo identity). *(BEA‑009, Deals #4.)*
3. **Group create 409**: attach `participant_index` to the slot‑conflict path. *(BEA‑011, GB #3.)*
4. **Shop view**: add a shop‑level payment block (`allowed_payment_methods` + deposit rules) to `GET /shops/:id`. *(BEA‑001, Pay #1 — value already computed for `payment-options`.)*
5. *(Optional)* embed `service_ids` inline on the specialist resource. *(GB #5 / Pay #5 — kills an N+1.)*

### B. Real backend work — **changes the shared contract / schema → MUST be agreed first** (per `CLAUDE.md` api‑impact rule)
1. **Per‑participant time slots in group booking** (GB #1) — changes the create payload + relaxes the deliberate shared‑start rule (BR‑G06). **Product decision.**
2. **Package redemption per participant in group booking** (GB #8) — new create contract + entitlement/ledger plumbing. **Product decision.**
3. **Per‑branch payment config** (Pay #2) — schema change (`branch` payment columns + inheritance).
4. **Error‑shape standardization** (Pay #4) — one shared error formatter, normalize the `errors` payload, catch framework errors. Coordinate with mobile's `network.dart` parser so the rollout is synchronized.

### C. Already live — **mobile updates integration** (no backend change)
- `max_group_size` is in `GET /shops/:id` (GB #2).
- `cancel-group` exists; last‑guest block is intentional and names it (GB #4).
- Cross‑specialist parallelism works; same‑specialist blocked by design (GB #6).
- Group booking supports online/deposit/on‑visit already (GB #7).
- Package redemption = `GET /subscription-package/redeemable` → `POST /subscription-package/redeem` (**not** a booking flag, **not** the legacy `book-package*` routes) (Pkg #5).
- Deals already carry `id`/`shop_id`/`shop_name`, `promo_code` (alias of `code`), `discount_type`/`discount_value` (Deals #1‑3).
- Deposit breakdown is on `POST /booking/payment-options` (Pay #3).
- Specialist services on `GET /agent/agents/agent-services?agent_id=` (Pay #5).

### D. Naming to agree jointly (one‑time)
- Packages: `validity_days` ⇄ mobile's `expiry_days` (Pkg #4).
- Payment: modes `on_visit/deposit/online` ⇄ mobile's `pay_at_venue/partial/full` (Pay vocab).
- Payment: `balance_due` ⇄ mobile's `remaining_amount_due_at_venue` (Pay #3).
- `terms_and_conditions`: no per‑package column today — either add one, or mobile uses `description` / shop‑level `shop.terms_conditions` (Pkg #4).

---

## Response to the mobile "Summary Action Plan for Backend Team"

| Mobile ask | Reality |
|---|---|
| GB: accept `time_slot` per participant | 🔴 New scope (shared‑start is by design) — needs product sign‑off |
| GB: add `max_group_size` in `GET /shops/:id` | ✅ Already there |
| GB: `participant_index` in 422 | 🟡 Present in pre‑validation; add it to the 409 slot‑conflict path |
| GB: link specialists with `service_ids` | 🟡 Served on `agent-services`; can inline |
| GB: parallel specialist bookings + all payment options (incl package) | ✅ Parallel + 3 payment modes already; 🔴 package redemption = new scope |
| Pay: expose `allowed_payment_methods` in `GET /shops/:id` | 🔴 Additive (today only on `payment-options`) — agree the vocabulary |
| Pay: return `remaining_amount_due_at_venue` in preparing‑booking | 🟡 Present as `balance_due` on `payment-options` |
| Pay: clarify branch strategy | 🔴 No per‑branch payment config exists |
| Pkg: add `eligible_service_ids`/`services` to `/my-packages` | 🔴 Additive (data exists) |
| Pkg: add `price`, `price_per_session`, `currency` | 🔴 `price`/`per_session_price` additive; `currency` is a product decision |
| Pkg: define `package_redemption_id` in checkout | ✅ Real contract exists — `/subscription-package/redeem` |
| Deals: standardize `id`/`shop_id`/`shop_name`/`promo_code`/`discount_type`/`discount_value` | ✅ All already returned by `DealResource` |
| Deals: full breakdown + auto default promo + dynamic remove/reapply | 🟡 Breakdown mostly there (add `valid_code`); remove/reapply work; auto‑best is the only genuine new feature |

---

*Evidence is `file:line` into this repo's `api/` tier as of 2026‑08‑04. Items in bucket **B** touch the shared mobile API contract and must be agreed before implementation per `CLAUDE.md`.*
