# NVG-BEA Mobile API Handoff Report

**Date:** 2026-07-26
**Audience:** mobile app team (customer app + specialist/agent app)
**Scope:** every new or modified `api/` (mobile-facing) endpoint shipped by the NVG-BEA
finance/booking program — BEA-008 (Subscription Packages), BEA-009 (Deals &
Promotions), BEA-010 (Specialist Wallet — wages), BEA-011 (Group Bookings).
**Companion file:** [`NVG_MOBILE_ENDPOINTS.postman_collection.json`](./NVG_MOBILE_ENDPOINTS.postman_collection.json)
— importable Postman v2.1 collection with the same endpoints, example bodies, and
per-request descriptions.

**Golden rule for this whole program:** every route below is **additive**. No existing
action, model contract, or response shape was removed or restructured — the two
"MODIFIED" endpoints (`GET /shops/:id`, `POST /booking/update-status`) only gained new
optional keys / new conditional branches. Existing mobile client code that doesn't read
the new keys is 100% unaffected and needs no changes to keep working.

---

## 1. Conventions (read this first)

- **Base URL / routing:** no path prefix — every route hangs directly off the API host
  root, e.g. `https://api.navagoo.com/my-packages`, `https://api.navagoo.com/shops/15`.
- **Auth:** `HttpBearerAuth` only — header `Authorization: Bearer <access_token>`. The
  token is `User.access_token`, a 40-char random string minted at sign-in/OTP-verify
  (`User::refreshAccessToken()`), with a finite expiry (`token_expires_at`, default 30
  days via `TOKEN_EXPIRATION_DAYS`). Two distinct token "audiences" exist — a
  **customer** token (`user_type = CUSTOMER`) for the CUSTOMER_APP endpoints, and an
  **agent** token (`user_type = AGENT`) for SPECIALIST_APP endpoints (only
  `/agent/wallet/wages` in this program). They are not interchangeable — e.g. calling
  `/agent/wallet/wages` with a customer token 404s ("Specialist not found").
- **Response envelope** (`api/helpers/ResponseHelper.php`, used by every action in this
  program):
  ```json
  // success
  { "success": true, "status": 200, "data": { /* payload, shape documented per-endpoint */ } }
  // failure
  { "success": false, "status": 422, "errors": [ { "MESSAGE": "human-readable string", "...": "endpoint-specific extra keys" } ] }
  ```
  `status` mirrors the actual HTTP status code. Always branch on `success`, not just the
  HTTP code, for consistency with the rest of the app's existing api/ integration.
- **Bilingual:** all `MESSAGE` strings and any `_ar`-suffixed fields (`package_name_ar`,
  etc.) come from `Yii::t()` and are already localized server-side to the request's
  resolved language (`?lang=en`/`ar` or `Accept-Language`, via `LangHelper`).

---

## 2. Endpoint reference table

| # | Method | Path | Auth | Feature | Purpose |
|---|---|---|---|---|---|
| 1 | POST | `/subscription-package/purchase` | customer | BEA-008 | Buy a pre-paid session package (Paymob-gated) |
| 2 | GET | `/my-packages` | customer | BEA-008 | List the customer's owned packages (entitlements) |
| 3 | GET | `/subscription-package/redeemable` | customer | BEA-008 | Which owned entitlements can be redeemed now for shop+service |
| 4 | POST | `/subscription-package/redeem` | customer | BEA-008 | Book a service by spending one session (no Paymob) |
| 5 | GET | `/shops/:id` | optional | BEA-008 + BEA-009 | **MODIFIED** — gained `subscription_packages` + `active_deals` keys |
| 6 | POST | `/booking/update-status` | customer | BEA-008 | **MODIFIED** — cancel-in-window now reinstates a package session |
| 7 | GET | `/shops/deals` | optional | BEA-009 | Global "Deals for you" feed |
| 8 | GET | `/shops/:id/applicable-deals` | optional | BEA-009 | Per-shop deals + live applicability for a cart |
| 9 | POST | `/booking/book` | customer | BEA-009 | **MODIFIED** — promo-code validation now delegates to `PromoCodeService` (same request shape) |
| 10 | POST | `/group-booking/create` | customer | BEA-011 | Create a multi-guest party booking |
| 11 | GET | `/group-booking/view/:id` | customer | BEA-011 | View a party (lead-booker scoped) |
| 12 | POST | `/group-booking/cancel-group` | customer | BEA-011 | Cancel every guest in a party |
| 13 | POST | `/group-booking/cancel-participant` | customer | BEA-011 | Cancel one guest |
| 14 | POST | `/group-booking/pay` | customer | BEA-011 | Settle the whole party (Paymob-gated) |
| 15 | GET | `/agent/wallet/wages` | agent | BEA-010 | Specialist commission + tips summary |

Full field-level request/response detail for every row is in the Postman collection's
per-request description — this report focuses on **flows** (which call happens when)
and the two cross-cutting handshakes (Paymob, envelope/auth) that don't belong to any
one endpoint.

---

## 3. Feature flows

### 3.1 Subscription Packages (BEA-008)

**Data model:** `subscription_package` (a shop's sellable package: name, price,
sessions, validity_days, per_session_price, included services/specialists) ->
`package_entitlement` (one row per customer purchase: `sessions_total`,
`sessions_remaining`, `expiry_date`, `status` = active/expired/exhausted/cancelled).

**Purchase flow:**
1. App renders package cards from the new `subscription_packages` key on
   `GET /shops/:id` (or wherever the shop page is loaded).
2. Customer taps "Buy" -> app runs the **Paymob SDK checkout** client-side for the
   package's `price` (VAT-inclusive).
3. On Paymob success, app calls `POST /subscription-package/purchase` with
   `subscription_package_id` + the Paymob `invoice_id` (transaction id).
4. Server verifies the transaction server-side, mints a `package_entitlement`
   (`sessions_remaining = sessions_total = pkg.sessions`, `expiry_date = today +
   validity_days`), and returns it. **Idempotent per `invoice_id`** — safe to retry
   the same call on any network error/timeout without risk of a duplicate purchase.
5. App shows the new entitlement; `GET /my-packages` is the source of truth for the
   "My Packages" screen going forward.

**Redeem-at-checkout flow (no Paymob):**
1. When the customer reaches checkout for a service at a shop, call
   `GET /subscription-package/redeemable?shop_id=&service_id=` for the cart's
   service(s). A non-empty result means "you can pay with a package" — surface it as
   an alternative to the normal Paymob checkout.
2. If the customer picks that option, call `POST /subscription-package/redeem` with
   the chosen `entitlement_id`, `service_id`, `agent_id`, and slot — this directly
   books the appointment (status SCHEDULED) and decrements the session count. There
   is no separate "book" + "pay" step for this path; redeem IS the booking.
3. `GET /my-packages` reflects the new `sessions_remaining` immediately.

**Cancellation / reinstatement:**
- A package-redeemed booking is cancelled through the **existing**
  `POST /booking/update-status` (`status=5`) call — no new endpoint. If the
  cancellation happens inside the shop's full-refund window, the consumed session is
  automatically given back (`sessions_remaining += 1`); outside that window, it is
  not. There is no cash refund path for a package booking (no Payment row exists) —
  `refund` in the response stays `null` either way; check `GET /my-packages`
  afterward to see whether the session came back.

**Expiry (server-side only, no app action):** a daily cron
(`console/controllers/PackageExpiryController::actionForfeit`) flips any `active`
entitlement whose `expiry_date` has passed to `status=expired`, forfeiting whatever
sessions remain (no cash, no partial credit). The same cron also fires one-time in-app
"expiring soon" notifications at T-7d and T-1d (existing Notifications feed — no new
notification endpoint, just new `module='package_entitlement'` rows the app's existing
notification list already renders generically).

### 3.2 Deals & Promotions (BEA-009)

**Discovery:**
- `GET /shops/deals` — cross-shop "Deals for you" feed (Discover-tab equivalent).
- `GET /shops/:id/applicable-deals?service_ids=` — same `Deal` shape, scoped to one
  shop, with `applicable`/`applicable_reason` computed against the current cart AND
  the authenticated customer (per-customer cap / first-time-only checks apply when
  authenticated; skipped when the caller is anonymous). Call this at checkout with
  the selected `service_ids` to auto-suggest a promo code.
- `GET /shops/:id` also carries a same-shape `active_deals` key now (no cart/customer
  context — just "what's live at this shop").

**Enforcement is unchanged in shape:** the existing `POST /booking/book` call still
takes `BookingForm[promo_code]` exactly as before. The only change is that the
server-side validation now runs the SAME rule set the Deals endpoints expose
(status/window/per-customer-cap/first-time-only/service-scope), instead of the old
looser expiry-only check. A code that fails validation is silently dropped (no
discount, booking still succeeds) — same fail-open UX as before. **Recommendation:**
call `applicable-deals` before checkout so the UI can tell the customer in advance
whether their code will actually apply, since `/booking/book` gives no explicit
"promo rejected" signal beyond the resulting discount being 0.

### 3.3 Group Bookings (BEA-011)

**Data model:** a "party" is a set of `booking` rows sharing one `group_booking_id`
(a short random string, not a numeric id) and one `customer_id` (the lead
booker/organiser — BR-G02). There is no separate `group_booking` table; the party is
a computed view over its child bookings.

**Create -> (pay) -> manage flow:**
1. `POST /group-booking/create` with the shop, a shared `appointment_date`, a
   `payment_timing` ('online' | 'deposit' | 'on_visit'), and a `participants[]` array
   (each with `service_ids`, optional `specialist_id` — omit to auto-assign, optional
   `is_organiser`). One specialist can only serve one guest (shared start time).
2. If `payment_timing` was `online`/`deposit`, every child is left pending payment
   until `POST /group-booking/pay` settles the WHOLE group with **one** Paymob
   transaction covering the party total (or per-guest deposits). If `on_visit`, the
   party is confirmed immediately with balance due at the shop — no pay call needed.
3. `GET /group-booking/view/:id` renders the party screen: summary (`party_size`,
   `value`, `outstanding`, `status`) + a `children[]` array, one row per guest.
4. Cancellation: `POST /group-booking/cancel-group` (everyone) or
   `POST /group-booking/cancel-participant` (one guest — blocked when they're the
   LAST active guest; cancel the whole party instead in that case). Each cancelled
   child gets the same zone-based refund a solo booking cancellation gets.

### 3.4 Specialist Wallet — wages (BEA-010)

Single read endpoint, `GET /agent/wallet/wages?date_from=&date_to=` (agent token,
default window = current calendar month). Surfaces `service_commission_earned` +
`tips_earned` + `total_earnings` for the window, using the exact same commission
engine (`WageEngineService::commissionInWindow`) the shop-portal Payroll tab uses — so
whatever a shop owner sees for a specialist's commission basis, the specialist sees the
same number in the app. `fixed_salary` is only folded into `total_earnings` for a
monthly pay-cycle specialist; otherwise it's `0` with `fixed_excluded_reason`
explaining why (don't hide that field — surface the reason string so the specialist
isn't confused by a $0 fixed salary in a non-monthly cycle).

---

## 4. The Paymob purchase/pay handshake (applies to 3 endpoints)

`POST /subscription-package/purchase`, `POST /group-booking/pay`, and the pre-existing
`POST /booking/pay` all use the **identical** contract — if the mobile team already
integrated the existing solo booking pay flow, this is the same code path reused:

1. **Client-side:** the app runs the Paymob SDK/checkout for the exact amount the
   server expects (package `price` VAT-incl; group total or per-guest deposit sum for
   group pay). Paymob returns a **transaction id**.
2. **Client -> server:** the app POSTs that transaction id as `invoice_id` (+ optional
   `integration_order_id`) to the Navagoo endpoint. Nothing else about the charge is
   trusted from the client.
3. **Server-side verification (mandatory, not optional):** the server calls
   `PaymobPaymentHelper::getPaymentStatus($invoice_id)` — a server-to-Paymob inquiry —
   and only proceeds if the gateway reports `Paid` with HTTP 200. It additionally reads
   `amount_cents` off that inquiry and REJECTS (422) if it falls short of the expected
   charge — this closes an underpayment gap the client-only flow would otherwise allow.
4. **Settlement is all-or-nothing:** entitlement/booking creation + fee/ledger rows
   commit together in one DB transaction; on any failure NOTHING is created and the
   app can safely retry the same `invoice_id`.
5. **Idempotency:** every one of these three endpoints is idempotent per `invoice_id`
   (`SELECT ... FOR UPDATE` row-lock + a UNIQUE index backing it) — a replayed call
   (dropped response, user double-tap, or the async Paymob webhook racing the
   synchronous call) returns the SAME success payload without creating a duplicate
   purchase/booking/payment. **The mobile team can blindly retry these calls on any
   ambiguous network failure** as long as the same `invoice_id` is reused.
6. **No separate "confirm" step:** unlike some payment integrations, there is no
   webhook the app needs to wait on — the synchronous response from step 2-4 IS the
   confirmation. (A Paymob webhook to `/webhook/paymob` also exists server-side purely
   as a reconciliation backstop and requires no mobile-side integration.)

`POST /subscription-package/redeem` deliberately does **NOT** use this handshake — it
has no `invoice_id` field at all because it never touches Paymob (the money was already
collected at purchase time).

---

## 5. Flagged product decisions the mobile team should know about

1. **Marketing fee is charged per-session-redeemed, not per-purchase, for
   navagoo-sourced customers.** When a customer bought a subscription package through
   the app (as opposed to a shop-owned/walk-in channel), Navagoo's marketing fee is
   split: a processing fee is taken once at `purchase`, but the marketing fee is
   deferred and charged incrementally, once per `redeem` call, based on the package's
   `per_session_price`. This is invisible to the mobile UI (it's a backend ledger
   detail, not a customer-facing charge — the customer never pays more than the
   package price they already paid) but it means: **cancelling a redeemed session
   inside the refund window also reverses that session's marketing-fee charge**
   server-side — nothing the app needs to do, just be aware the reinstatement is a
   full undo (session AND ledger), not just a UI-visible session count change.

2. **Per-participant classification in group bookings.** Each child booking in a party
   is classified (shop-owned vs navagoo-sourced, which drives Navagoo's fee) using the
   SAME rule as a solo app booking (`booking_method = 'mobile'`) — there is no
   party-level classification. This matters if the mobile team ever surfaces
   per-guest pricing/fee breakdowns: two guests in the same party can, in principle,
   be classified differently if the underlying classification service's rules key off
   something guest-specific (e.g. first-time-customer status) rather than purely the
   booking channel. In practice, for a party created entirely through the app, all
   children share `booking_method='mobile'` and typically classify the same way — but
   don't assume this is guaranteed by the schema.

3. **`GET /group-booking/view/:id` and both cancel endpoints 404 (not 403) when the
   party isn't yours.** This is deliberate (no existence oracle / IDOR hardening) —
   the app should treat a 404 on a group-booking id it just created/has locally as "no
   longer accessible", not surface a raw "not found" error that implies the id was
   fabricated.

4. **`applicable-deals` and `redeemable` are both "hint" endpoints, not guarantees.**
   Both re-validate the underlying condition (promo eligibility / entitlement
   redeemability) again at the actual write endpoint (`/booking/book`,
   `/subscription-package/redeem`). A deal or entitlement shown as usable in the
   discovery call can still be rejected at write time under a race (e.g. someone else
   used the last redemption of a capped promo, or a concurrent redeem exhausted the
   entitlement). Always handle the write endpoint's own error response, don't treat
   the discovery call's answer as final.

---

## 6. End-to-end verification performed (2026-07-26, live dev DB)

A complete Subscription Packages cycle was driven against the real dev database and
API (`api.navagoo.localhost`, docker `projects-webserver`) — shop-side package
creation through customer-side purchase, redemption, cancellation-reinstatement, and
expiry forfeiture. All rows created for the test were deleted afterward and verified
as **0 leaked** (see step-by-step pass/fail list below, relayed separately). Real
HTTP calls were used for every step except the Paymob-gated purchase itself, which was
proven at the service layer (`PackagePurchaseService::createEntitlementFromPaidPurchase`,
the exact function the purchase controller action delegates to after verifying Paymob)
since a live Paymob transaction cannot be produced from an automated script — the
purchase controller's OWN Paymob-verification code (`PaymobPaymentHelper::
getPaymentStatus` + amount-cents guard) is identical to the already-proven-in-production
solo booking pay flow's, so this is a faithful proxy, not a gap.

Test shop: `Beauty Center` (id 15, existing dev shop). Test customer: an existing dev
customer account (id 16), whose `access_token` was rotated via the same
`User::refreshAccessToken()` method the real login flow calls — no new user rows were
created, only a token rotation (equivalent to a normal login) and, later, DB rows tied
to the package/entitlement/booking under test, all deleted at cleanup.
