# API Contracts Specification

## 1. Base URL & Versioning
- **Base URL:** `/api/v1`
- **Authentication:** Bearer Token (Sanctum)

## 2. Standard Response Format
All API endpoints must return data in the following structure:
```json
{
  "status": true,
  "message": "Operation completed successfully.",
  "data": { ... }
}
```
Errors should follow:
```json
{
  "status": false,
  "message": "Validation failed.",
  "errors": {
     "field_name": ["Error detail here"]
  }
}
```

## 3. Endpoints

### 3.1. [Module Name]

#### GET `/api/v1/resource`
- **Description:** List all resources.
- **Query Parameters:**
  - `page` (int): Pagination offset.
  - `q` (string): Search query.
- **Response:** 200 OK

#### POST `/api/v1/resource`
- **Description:** Create a new resource.
- **Body:**
  ```json
  {
    "field1": "value"
  }
  ```
- **Response:** 201 Created

### 3.2. Group Bookings (NVG-BEA-011 W3 — customer app)

New, **additive-only** endpoints — no existing endpoint, model contract or response
shape changed. Controller: `api/controllers/GroupBookingController.php`; routes in
`api/config/_urlManager.php`. Auth: Bearer token (customer). Envelope: the api tier's
actual standard — `{"success": bool, "status": int, "data": ...}` on success,
`{"success": false, "status": int, "errors": {...}}` on failure (`ResponseHelper`).
Unauthenticated calls return the framework 401 envelope.

A **group** is N child bookings sharing one `group_booking_id` (`GRP-YYMM-XXXXX`).
The authenticated customer is the **lead booker** (customer-of-record on every child);
all endpoints are lead-scoped — a group belonging to another user is a 404.

#### POST `/group-booking/create`
- **Description:** Create a party (single day, one shared start time). Max party size =
  shop override ?? platform `commercial_config.max_group_size` ?? 10.
- **Body:**
  ```json
  {
    "shop_id": 1,
    "appointment_date": "2026-08-01 14:00",
    "payment_timing": "online",
    "participants": [
      { "name": "Me", "service_ids": [11, 12], "specialist_id": 7, "is_organiser": true },
      { "name": "Sara", "service_ids": [11] }
    ]
  }
  ```
  - `payment_timing`: `online` | `deposit` | `on_visit` (must be enabled for the shop).
  - `specialist_id` optional → auto-assigned (first active, capable, free specialist).
    Specialists must be distinct across participants (shared start).
  - `is_organiser` optional; defaults to participant 0.
- **Status semantics:** `online`/`deposit` → children created in **pending-payment**
  (`status = 1`, `STATUS_SELECTED_NOT_PAID`, same as the solo unpaid flow; hidden from
  the booking list) until `/group-booking/pay` settles the whole group. `on_visit` →
  confirmed immediately (`status = 2`, SCHEDULED), balance due at the shop.
  Children are stamped `booking_method = "mobile"` (app-sourced classification).
- **Response:** 201 Created
  ```json
  {
    "success": true, "status": 201,
    "data": {
      "group_booking_id": "GRP-2608-A1B2C",
      "appointment_date": "2026-08-01 14:00:00",
      "party_size": 2, "value": 350.0, "outstanding": 350.0,
      "duration_min": 60, "status": 1,
      "children": [
        {
          "id": 501, "guest_label": "Me", "is_group_organiser": true,
          "specialist_id": 7, "specialist_name": "Aya", "service_ids": [11, 12],
          "booking_date": "2026-08-01 14:00:00", "from_hour": "14:00", "to_hour": "15:00",
          "status": 1, "payment_mode": "online",
          "total_amount": 250.0, "amount_collected": 0.0, "balance_due": 250.0,
          "refund_value": null
        }
      ]
    }
  }
  ```
- **Errors:** 422 validation (structured per-participant errors carry
  `participant_index`, 0-based, plus `MESSAGE`); 409 placement conflict — the service's
  per-guest error is surfaced **verbatim** as `MESSAGE`, formatted
  `"<guest label> · <specialist name>: <reason>"` (unlabelled guests carry the lead
  booker's name); 404 unknown/inactive shop. On ANY failure nothing is created
  (whole-party atomicity).

#### GET `/group-booking/view/<group_booking_id>`
- **Description:** Lead-scoped summary + children (same `data` shape as create).
- **Response:** 200 OK; 404 when the group doesn't exist or isn't the caller's.

#### POST `/group-booking/cancel-group`
- **Body:** `{ "group_booking_id": "GRP-2608-A1B2C" }`
- **Description:** Cancel every non-terminal child as a CUSTOMER cancellation —
  standard per-booking policy per child (refund zone full/partial/none via the
  canonical ledger, deposit non-refundable, `status = 5` CANCELED,
  `cancelled_by = "customer"`). Atomic. Returns the updated group summary
  (`refund_value` per child).
- **Response:** 200 OK; 404 not-owned/unknown; 422 on service failure.

#### POST `/group-booking/cancel-participant`
- **Body:** `{ "group_booking_id": "GRP-2608-A1B2C", "booking_id": 502 }`
- **Description:** Cancel ONE child (same customer policy); siblings untouched.
  **Last-child guard:** the last remaining active guest cannot be cancelled here —
  422 directs the caller to cancel-group.
- **Response:** 200 OK with updated summary; 404 group/participant not found; 422
  already closed out / last active guest.

#### POST `/group-booking/pay`
- **Description:** Settle the WHOLE group with **one Paymob transaction**. The app
  charges one Paymob order for the group total (sum of children totals, or sum of
  per-child deposits in `deposit` mode) and posts the transaction id here.
- **Body:**
  ```json
  {
    "group_booking_id": "GRP-2608-A1B2C",
    "invoice_id": "271828182",
    "integration_order_id": "314159",
    "payment_mode": "online"
  }
  ```
  `payment_mode` optional (`online`|`deposit`), defaults to the mode chosen at create.
- **Behaviour:**
  - Server verifies the transaction with Paymob (inquiry) and rejects when
    `amount_cents` is below the expected group charge (422 — no partial confirmation).
  - **Idempotent:** a `SELECT … FOR UPDATE` on `payment.tran_ref` (+ the unique
    `tran_ref` index) means replays/webhook races return success without re-running
    side effects — the same guard idiom as the solo `/booking/pay`.
  - On success, ONE atomic transaction flips every pending child to SCHEDULED
    (`amount_collected`/`balance_due`/`deposit_amount` stamped per mode,
    `invoice_id = tran_ref` on each child) and writes ONE Payment (+Transaction) row
    for the group total, attached to the **organiser child** (payment has no meta
    column; reconciliation = organiser child → `group_booking_id`, or any child by
    `invoice_id`). The existing `/webhook/paymob` settles this row by `tran_ref`
    with no booking-type assumptions.
  - **On failure nothing confirms:** gateway not Paid / amount short / DB error ⇒
    every child stays in pending-payment (`status = 1`); the whole group fails
    together.
- **Response:** 200 OK with updated group summary; 404 gateway-not-paid or unknown
  group; 409 nothing awaiting payment (and unknown invoice); 422 mode not allowed /
  underpayment.

### 3.3. Specialist Wallet — Wages (NVG-BEA-010 W3 — specialist app)

**New, additive-only endpoint** — no existing action, model contract, or response
shape changed. Controller: `api/controllers/agent/WalletController.php::actionWages`
(new method appended to the existing `agent/wallet` controller alongside `index` /
`withdrawals` / `withdraw`, which are untouched). Route added in
`api/config/urls/_AgentUrls.php`. Auth: Bearer token (specialist/agent — the same
`CompositeAuth` + `HttpBearerAuth` filter as every other `agent/*` action). Envelope:
the api tier's standard — `{"success": bool, "status": int, "data": ...}` on success,
`{"success": false, "status": int, "errors": {...}}` on failure (`ResponseHelper`).
Unauthenticated calls return the framework's own 401 (same as the sibling `agent/wallet`
actions — the bearer-auth filter runs before the controller action).

> **Mobile team flag:** this is a brand-new contract, additive only — no existing
> `agent/wallet/*` route, field, or type changed. Safe to build against immediately;
> no client migration required for existing wallet screens.

#### GET `/agent/wallet/wages`
- **Description:** Wages/commission summary for the authenticated specialist over a
  reporting window. Commission is computed via
  `common\components\WageEngineService::commissionInWindow()` — the SAME public
  engine method the shop-portal Team → Payroll tab uses (via `WagePayrollService`) —
  so it automatically reflects the NVG-BEA-010 W2 commission-basis alignment
  (pre-VAT/post-discount `service_value` basis) with no math duplicated in the API
  controller. Tips are `SUM(booking.specialist_tip)` over the specialist's completed
  bookings in the window (the M3-authoritative tip column).
- **Query params:**
  - `date_from` (`YYYY-MM-DD`, optional) — defaults to the 1st of the current
    calendar month.
  - `date_to` (`YYYY-MM-DD`, optional) — defaults to the last day of the current
    calendar month.
- **Response:** 200 OK
  ```json
  {
    "success": true, "status": 200,
    "data": {
      "wage_type": "both",
      "pay_cycle": "monthly",
      "fixed_salary": 3000.0,
      "commission_pct": 10.0,
      "service_commission_earned": 245.5,
      "tips_earned": 80.0,
      "total_earnings": 3325.5,
      "fixed_excluded_reason": null,
      "period": { "from": "2026-07-01", "to": "2026-07-31" }
    }
  }
  ```
  - `fixed_salary` is always the specialist's configured monthly figure, but it is
    only folded into `total_earnings` when `pay_cycle = "monthly"` **and**
    `wage_type != "commission"` (W1 policy — a fixed salary is a whole-month figure
    and would misstate an arbitrary window on a weekly/biweekly cycle). When
    excluded, `fixed_excluded_reason` carries a bilingual `Yii::t('backend', ...)`
    note explaining why (commission-only specialist, or non-monthly cycle); when
    included it is `null`.
  - All money fields rounded to 2dp (`WageEngineService::round2`).
- **Errors:** 404 specialist/profile not found (no `user_profile` row —
  `wage_type`/`commission_pct`/etc. never configured); 422 invalid `date_from`/
  `date_to` (unparsable or `date_to` before `date_from`); 401 unauthenticated
  (framework envelope, not `ResponseHelper`).

### 3.4. Deals & Promotions (NVG-BEA-009 W4 — customer app)

**Additive-only surfaces** — no existing route, field, or response type changed or
removed. Controller: `api/controllers/ShopsController.php` (new `actionApplicableDeals`
+ one new key merged into `actionView`'s payload). Routes in
`api/config/urls/_CustomerUrls.php`. Auth: **optional** Bearer (same `CompositeAuth`
optional list as `GET /shops/deals`). Envelope: standard
`{"success": bool, "status": int, "data": ...}` via `ResponseHelper`.

> **Mobile team flag:** both surfaces are additive. Existing shop-details consumers are
> unaffected — `active_deals` is one NEW top-level key on the same payload; every
> pre-existing key is serialized by the same `ShopsResource::fields()` as before.
> The `Deal` object shape is identical to `GET /shops/deals` (BEA-009 discovery feed);
> `applicable-deals` extends it with two extra keys.

#### Shared `Deal` shape

```json
{
  "id": 12,
  "code": "SUMMER20",
  "discount_type": "percentage",        // "percentage" | "fixed"
  "discount_value": 20.0,
  "usage_cap": 100,                     // null ⇒ unlimited
  "usage_count": 42,
  "remaining_uses": 58,                 // null when uncapped
  "expiry": "31/12/2026",               // legacy d/m/Y string, null when unset
  "shop": { "shop_id": 3, "shop_name": "…", "image": "…" }  // null ⇒ platform-wide
}
```

#### GET `/shops/<id>` — additive `active_deals` key
- **Description:** the shop-details payload now carries `active_deals`: the
  currently-live deals for that shop — its own shop-scoped promo codes **plus**
  platform-wide (null-shop) codes — filtered by
  `PromoCodeService::isVisible()` (status ACTIVE + inside its `active_from`/
  `active_until` window incl. legacy `expiry_date` fallback + total usage cap not
  reached). Array of the shared `Deal` shape above; `[]` when none. All other keys
  untouched.

#### GET `/shops/<id>/applicable-deals?service_ids=1,2`
- **Description:** auto-apply support — the visible deals for ONE shop, each annotated
  with whether it is redeemable *right now* for the caller via
  `PromoCodeService::validate()` (the same validator `BookingForm` enforces at
  checkout).
- **Query params:** `service_ids` (optional, comma-separated shop_service ids — the
  candidate cart, used for the `service_scope` check; non-numeric segments dropped).
- **Auth:** optional. Unauthenticated callers get the same list, but the
  per-customer-cap and first-time-only checks are **skipped** (validated with
  `customer_id = null` — there is no customer to check against), so `applicable` can
  be `true` for a code the customer will still be denied at checkout. This endpoint is
  a display/auto-apply *hint*; booking-time validation remains the enforcement path.
- **Response:** 200 OK — `data` = array of the shared `Deal` shape **plus**:
  - `applicable` (bool) — `validate()` passed for this caller + cart.
  - `applicable_reason` (string|null) — `null` when applicable; otherwise one of the
    validator reason codes: `inactive` · `wrong_shop` · `not_started` · `expired` ·
    `total_cap_reached` · `per_customer_cap_reached` · `first_time_only` ·
    `service_not_in_scope`. Internal codes, not display strings — the app maps them
    to localized copy.
- **Errors:** 404 shop not found (or outside the caller's demo/live data world —
  same rule as `GET /shops/<id>`).

#### Fix note — package-booking promo redemption recording
`BookingController::actionBookPackageServices` persists its `Booking` row directly
(not via `BookingForm::save()`), so W2's redemption recording never fired for package
bookings (flagged gap). W4 wires `PromoCodeService::recordRedemption()` into that
action immediately after the booking save succeeds — mirroring
`BookingForm::recordPromoRedemption()` (post-persist only, never during the
preview-only `preparing-booking-package` call; idempotent per booking via the
`user_promo_code.booking_id` guard). **No request/response shape changed** — package
promo redemptions now count against `uses`/`remaining_uses`/per-customer caps exactly
like services bookings.

### 3.5. Subscription Packages (NVG-BEA-008 W3 — customer app)

**Additive-only surfaces** — no existing route, field, or response type changed or
removed. NEW controller: `api/controllers/SubscriptionPackageController.php`. Routes
in `api/config/urls/_CustomerUrls.php`. Auth: **required** Bearer
(`MyActiveController`'s default `CompositeAuth`/`HttpBearerAuth`) for both purchase
and my-packages. Envelope: standard `{"success": bool, "status": int, "data": ...}`
via `ResponseHelper`. Business logic lives in `PackagePurchaseService` (entitlement
minting) and `FinanceLedgerService::buildPackagePurchaseCharges()` (processing +
marketing fee, additive method) — this controller is transport only.

> **Mobile team flag:** this is a NEW customer-facing purchase flow — flagging per the
> shared-API rule even though nothing existing changes. `GET /shops/<id>` gains ONE
> additive `subscription_packages` key (empty array when the shop has none); every
> pre-existing key is byte-identical.

#### POST `/subscription-package/purchase`
- **Description:** purchase a subscription package at full (VAT-incl) price via ONE
  Paymob transaction. Mirrors `BookingController::actionPay` /
  `GroupBookingController::actionPay`: the app completes the Paymob checkout
  client-side (SDK) and posts the resulting transaction id here; the server verifies
  it via `PaymobPaymentHelper::getPaymentStatus()` before minting anything, and
  rejects when the gateway's `amount_cents` falls short of the package price. On
  settle, ONE transaction creates the `PackageEntitlement` (session balance +
  expiry + price snapshot) and stamps the purchase's processing fee (+ marketing
  fee when the customer is navagoo-sourced) — all-or-nothing; on any failure
  nothing is created and the app may safely retry with the same `invoice_id`.
- **Body:**
  ```json
  {
    "subscription_package_id": 12,
    "invoice_id": "123456789",           // Paymob transaction id
    "integration_order_id": "987654321"  // optional
  }
  ```
- **Response:** 201 Created (or 200 on an idempotent replay of an already-settled
  `invoice_id`)
  ```json
  {
    "success": true,
    "status": 201,
    "data": {
      "id": 45,
      "shop_id": 7,
      "subscription_package_id": 12,
      "package_name": "Glow Package",
      "package_name_ar": "باقة النضارة",
      "sessions_total": 5,
      "sessions_remaining": 5,
      "expiry_date": "2026-09-24",
      "purchase_date": 1785000000,
      "price": 250.0,
      "vat_amount": 32.61,
      "per_session_price": 50.0,
      "status": "active",
      "payment": { "tran_ref": "123456789", "order_id": "987654321" }
    }
  }
  ```
- **Errors:** 404 invalid input / package not found or not available / shop not
  found or not active / gateway payment not `Paid`; 422 package outside its
  availability window / paid amount short of the package price; 500 on a settle
  failure (nothing is created).

#### GET `/my-packages`
- **Description:** the authenticated customer's owned packages — active, expired,
  exhausted, and cancelled alike (the app filters by `status` client-side, same
  idiom as the booking list). One query with eager-loaded relations, no N+1.
- **Response:** 200 OK
  ```json
  {
    "success": true,
    "status": 200,
    "data": [
      {
        "id": 45,
        "subscription_package_id": 12,
        "package_name": "Glow Package",
        "package_name_ar": "باقة النضارة",
        "sessions_total": 5,
        "sessions_remaining": 3,
        "expiry_date": "2026-09-24",
        "purchase_date": 1785000000,
        "status": "active",
        "shop": { "id": 7, "name": "Madina", "image": "…" }
      }
    ]
  }
  ```

#### GET `/shops/<id>` — additive `subscription_packages` key
- **Description:** the shop-details payload now carries `subscription_packages`:
  the shop's ACTIVE, non-hidden, in-availability-window (`start_date`/`end_date`,
  both optional) pre-paid session packages, for the app's shop page. `[]` when
  none. All other keys untouched.
  ```json
  "subscription_packages": [
    {
      "id": 12,
      "name": "Glow Package",
      "name_ar": "باقة النضارة",
      "image": null,
      "price": 250.0,
      "per_session_price": 50.0,
      "sessions": 5,
      "validity_days": 60,
      "services": [ { "id": 74, "name": "Facial" } ]
    }
  ]
  ```
  Note: `image` is passed through raw from `subscription_package.image` (a single
  relative-path/URL column added in W1 — unlike the rest of the codebase's
  two-column `image_path`/`image_base_url` convention). No URL-building
  convention has been decided for it yet; that lands with W2's upload wiring.

#### Charge ledger note (booking_id is NULL for purchase charges)
`Charge.booking_id` is nullable at the DB level and a package purchase has no
booking row of its own, so `buildPackagePurchaseCharges()` stamps `booking_id =
NULL` — the SAME convention already used for `TYPE_SUBSCRIPTION` (Navagoo SaaS
billing) charges. The entitlement is tagged via `meta.entitlement_id`
(`JsonExpression`) instead, both for idempotency (a purchase's charges are stamped
exactly once) and for reconciliation. Per-booking finance views
(`bookingFeesIncurred`, `settlementFeeRows`, …) correctly ignore these rows;
shop-level views that don't filter on `booking_id` (`costsToDate`, `shopPnl`'s
cost side) include them like any other shop-level fee.

### 3.5.1 Subscription-Package Redemption (NVG-BEA-008 W4 — customer app)

**Additive-only surfaces.** New actions on the SAME `SubscriptionPackageController`
(`GET /subscription-package/redeemable`, `POST /subscription-package/redeem`),
routes in `_CustomerUrls.php` (`only` extended to add `redeemable`/`redeem`; the
existing `purchase` rule is untouched). Also touches, additively only:
- `api/controllers/BookingController.php` — a NEW private
  `reinstatePackageRedemption()` helper + a single new `if` branch inside the
  existing customer-cancel path of `actionUpdateStatus()` (see "Cancel-reinstate"
  below). No other line of `actionUpdateStatus()`, nor any other action, changed.
- `api/resources/BookingResource.php` — the `payment_message` field gained ONE new
  `if` branch for `Booking::PAYMENT_MODE_PACKAGE` (so a redemption booking doesn't
  render the misleading "Paid in full online."). Every other `payment_mode` branch
  is byte-identical.
- `common/models/Booking.php` / `common/models/base/Booking.php` — a NEW
  `payment_mode` value `Booking::PAYMENT_MODE_PACKAGE` = `'package'`, and a NEW
  nullable `booking.package_entitlement_id` column (migration
  `m260726_170000_add_package_entitlement_id_to_booking`, no FK — see the
  migration's docblock for why the pre-existing `booking.package_id` column was
  deliberately NOT reused: it belongs to the unrelated OLD `Package`/`UserPackage`
  bundle system and reusing it would have silently corrupted every existing read
  of that column for a redemption booking).
- `common/components/FinanceLedgerService.php` — ADDITIVE only, appended at the
  end of the class: `buildPackageRedemptionCharges()`, `reversePackageRedemptionCharge()`,
  and a private `packageRedemptionChargeExists()` guard. Nothing above them changed.

> **Mobile team flag:** two new endpoints + the `payment_mode` field on a booking
> can now be the new value `"package"` (in addition to the existing `"online"` /
> `"deposit"` / `"on_visit"`) — apps that switch on `payment_mode` should treat an
> unrecognised value defensively regardless, but this is called out explicitly
> since it's a genuinely new enum member on a shared response field.

#### GET `/subscription-package/redeemable?shop_id=<id>&service_id=<shop_service_id>`
- **Description:** the authenticated customer's entitlements that can be redeemed
  RIGHT NOW for `service_id` (a `shop_service` id) at `shop_id` — lets the app
  offer "use a package session" instead of paying. A redeemable entitlement is:
  owned by this customer, sold by this shop, currently
  `PackageEntitlement::isRedeemable()` (active status, ≥1 session left, not past
  `expiry_date`), and its package's included-services list
  (`subscription_package_service`) contains `service_id`. ONE query (join on the
  services junction, `service_id` here means a `shop_service` id — the same
  convention `BookingForm.services_ids` and `UserShopService.service_id` already
  use, NOT the generic `service` table's id).
- **Response:** 200 OK
  ```json
  {
    "success": true,
    "status": 200,
    "data": [
      {
        "id": 45,
        "subscription_package_id": 12,
        "package_name": "Glow Package",
        "package_name_ar": "باقة النضارة",
        "sessions_total": 5,
        "sessions_remaining": 3,
        "expiry_date": "2026-09-24",
        "per_session_price": 50.0
      }
    ]
  }
  ```
- **Errors:** 404 invalid/missing `shop_id`/`service_id`.

#### POST `/subscription-package/redeem`
- **Description:** book `service_id` (a `shop_service` id) with `agent_id` at
  `booking_date`/`from_hour`–`to_hour` by redeeming ONE session from
  `entitlement_id` — skips Paymob entirely. Everything (re-validation of
  eligibility, booking creation, slot allocation, the session decrement, and the
  redemption marketing-fee charge) happens inside ONE transaction with
  `SELECT ... FOR UPDATE` on the entitlement row (same idiom
  `PackagePurchaseService`/`actionPay` use for their own idempotency locks) plus
  the SAME `agent_slots` conflict-lock query `BookingController::actionBook` uses
  (SECURITY Fix 2.4) for the slot placement check — a concurrent double-book or a
  concurrent double-spend of the same entitlement both block until this commits.
  On ANY failure, nothing is created and NO session is consumed.

  A NEW dedicated action rather than a param on `actionBook`/`actionBookingServices`
  — deliberately, so the existing Paymob-backed cart book/pay flow stays
  byte-for-byte untouched.

  The resulting `Booking` row: `status = SCHEDULED` (the same "confirmed, no
  payment due" status a normal booking lands in once Paymob settles),
  `payment_mode = "package"`, `package_entitlement_id` set,
  `amount_collected = 0` / `balance_due = 0` / `deposit_amount = 0` (nothing is
  collected online this booking — it was pre-paid at package-purchase time), while
  `sub_amount`/`vat`/`total_amount` are still populated at the service's real
  value (parity with a normally-paid booking for shop revenue/agent-stats
  reporting).

  Charges: **marketing fee ONLY**, gated on `navagoo_sourced` (same gate every
  other marketing fee in the app uses) — basis = `noVat(entitlement.per_session_price)`
  (i.e. `package.price ÷ package.sessions`, per the plan's documented decision),
  stamped `UNPAID` immediately (the redemption event is final the instant the
  transaction commits — mirrors how `buildPackagePurchaseCharges()` reasons about
  its own purchase-time marketing fee). **NO processing fee is ever stamped** —
  nothing was collected online this booking; the purchase already carried its own
  processing fee.

  When the redeemed session was the entitlement's last one, `sessions_remaining`
  hits `0` and `PackageEntitlement.status` flips to `exhausted`.
- **Body:**
  ```json
  {
    "entitlement_id": 45,
    "service_id": 74,
    "agent_id": 12,
    "booking_date": "2026-08-01",
    "from_hour": "10:00",
    "to_hour": "10:30"
  }
  ```
- **Response:** 201 Created
  ```json
  {
    "success": true,
    "status": 201,
    "data": {
      "booking": {
        "id": 9001,
        "status": 2,
        "booking_date": "2026-08-01",
        "from_hour": "10:00",
        "to_hour": "10:30",
        "agent_id": 12,
        "shop_id": 7,
        "total_amount": 115.0,
        "payment_mode": "package"
      },
      "entitlement": { "id": 45, "sessions_total": 5, "sessions_remaining": 2, "status": "active" }
    }
  }
  ```
- **Errors:** 404 invalid input / entitlement not found or not owned by the
  caller / shop not found or not active / service not found; 422 entitlement has
  no sessions left or has expired / service not included in the package /
  specialist doesn't offer the service or isn't on the package's eligible-
  specialist list (an EMPTY eligible-specialist list means "no restriction" — the
  same NULL-means-unrestricted convention `subscription_package.active_days`
  already uses); 409 the requested slot is no longer available; 500 on a settle
  failure (nothing is created, no session consumed).

#### Cancel-reinstate: customer cancel of a redemption booking
No new endpoint — the existing `POST /update-status` (customer cancel, i.e.
`BookingController::actionUpdateStatus()`) gained ONE additive `if` branch inside
its pre-existing `status == STATUS_CANCELED` block: when the booking being
cancelled carries a `package_entitlement_id`, a NEW private
`reinstatePackageRedemption()` helper runs (every other booking's cancel path is
untouched).

That helper is a no-op unless the cancellation falls **within the shop's
full-refund window** (`FinanceLedgerService::refundZoneFor() === Booking::FULL_REFUND`
— the SAME policy gate a normally-paid booking's cash refund uses; there is no
partial-reinstatement concept, since nothing was collected in cash to partially
refund). Within that window it:
1. `PackageEntitlement::reinstateSession()` (`sessions_remaining++`; flips
   `exhausted` back to `active` when capacity allows) — under a
   `SELECT ... FOR UPDATE` row lock so it can't race a concurrent redeem/cancel.
2. `FinanceLedgerService::reversePackageRedemptionCharge()` — a full (100%)
   negative offsetting `Charge` row reversing the redemption's own marketing fee,
   reusing the SAME `buildReversal()` idiom every other marketing-fee reversal in
   the app uses (append-only ledger; the original row is never deleted/mutated).

No cash refund is attempted for a redemption booking (there is no `Payment` row —
`BookingHelper::customerCancelBooking()`'s refund branch is already a no-op for
it). Best-effort by design: a failure inside `reinstatePackageRedemption()` is
logged (`Yii::error`, category `subscription-package`) but never blocks the
booking's own cancellation from completing.
