{
  "info": {
    "_postman_id": "b9b3f3b0-6f2a-4a2c-9c7b-nvg-bea-mobile-2026",
    "name": "Navagoo Mobile API — BEA Program (008/009/010/011)",
    "description": "New and modified CUSTOMER_APP / SPECIALIST_APP endpoints shipped by the NVG-BEA finance/booking program (BEA-008 Subscription Packages, BEA-009 Deals & Promotions, BEA-010 Specialist Wallet, BEA-011 Group Bookings). All routes are additive on top of the existing api/ tier — no existing action, model contract, or response shape was changed except where explicitly called out on the request itself (search 'MODIFIED' in each such request's description).\n\nAuth: HttpBearerAuth only (Authorization: Bearer <token>). No path prefix — every route hangs directly off the API host root (e.g. https://api.navagoo.com/my-packages).\n\nEnvelope (every response, success or failure):\n  success -> { success: true, status: <int>, data: <payload> }\n  failure -> { success: false, status: <int>, errors: [ { MESSAGE: string, ... } ] }\n\nVerified against the live dev DB 2026-07-26 via a real end-to-end Subscription Packages cycle (purchase -> my-packages -> redeemable -> redeem -> cancel-in-window reinstatement -> expiry forfeit). See NVG_MOBILE_HANDOFF_REPORT.md for the full cycle log and Paymob handshake contract.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      { "key": "token", "value": "{{customer_token}}", "type": "string" }
    ]
  },
  "variable": [
    { "key": "base_url", "value": "https://api.navagoo.com", "type": "string", "description": "Prod: https://api.navagoo.com . Local dev: http://api.navagoo.localhost (send with Host header if hitting 127.0.0.1 directly)." },
    { "key": "customer_token", "value": "PASTE_CUSTOMER_ACCESS_TOKEN", "type": "string", "description": "40-char access_token minted at customer sign-in/OTP-verify (User.access_token)." },
    { "key": "agent_token", "value": "PASTE_SPECIALIST_ACCESS_TOKEN", "type": "string", "description": "40-char access_token for a SPECIALIST_APP (agent) user — used only by the Specialist Wallet folder." },
    { "key": "shop_id", "value": "15", "type": "string" },
    { "key": "service_id", "value": "47", "type": "string" },
    { "key": "agent_id", "value": "39", "type": "string" },
    { "key": "subscription_package_id", "value": "12", "type": "string" },
    { "key": "entitlement_id", "value": "12", "type": "string" },
    { "key": "group_booking_id", "value": "GB-ABCD1234", "type": "string" },
    { "key": "booking_id", "value": "1266", "type": "string" }
  ],
  "item": [
    {
      "name": "Subscription Packages (BEA-008)",
      "description": "Pre-paid session-block packages a shop sells and a customer redeems across future visits. New table `subscription_package` (+2 junctions) + `package_entitlement` (the customer's owned purchase record: sessions_total/sessions_remaining/expiry_date/status). Purchase settles via ONE Paymob transaction (same handshake as the existing solo booking pay flow); redeem/cancel-reinstate never touch Paymob.",
      "item": [
        {
          "name": "Purchase a package [Paymob-gated]",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/subscription-package/purchase",
              "host": ["{{base_url}}"],
              "path": ["subscription-package", "purchase"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "subscription_package_id", "value": "{{subscription_package_id}}", "description": "int, required — the SubscriptionPackage being bought." },
                { "key": "invoice_id", "value": "PAYMOB_TRANSACTION_ID", "description": "string, required — the Paymob transaction id returned to the app after the client completes the Paymob SDK checkout for the package's VAT-incl price (pkg.price)." },
                { "key": "integration_order_id", "value": "PAYMOB_ORDER_ID", "description": "string, optional — Paymob order id." }
              ]
            },
            "description": "PAYMOB-GATED. Client flow: 1) app renders the package card (from the `subscription_packages` key on GET /shops/:id) 2) app runs the Paymob SDK checkout for `price` (VAT-incl) 3) on Paymob success, POST here with the resulting `invoice_id`.\n\nServer verifies the transaction server-side via PaymobPaymentHelper::getPaymentStatus (must be 'Paid', HTTP 200) and rejects if Paymob's amount_cents falls short of the expected price (422). On success, mints ONE `package_entitlement` row (sessions_total = pkg.sessions, sessions_remaining = sessions_total, expiry_date = today + pkg.validity_days, price/vat_amount/per_session_price snapshotted from the package at purchase time) + stamps a processing-fee (+ marketing-fee if navagoo-sourced) Charge — both inside one all-or-nothing transaction.\n\nIdempotent per `invoice_id`: a replayed call with the same `invoice_id` (e.g. a retried request after a dropped response) returns the SAME entitlement, 200, no duplicate — safe to retry blindly on any network error.\n\nResponses: 201 (new entitlement) or 200 (idempotent replay) -> entitlement object (id, shop_id, subscription_package_id, package_name/_ar, sessions_total, sessions_remaining, expiry_date, purchase_date, price, vat_amount, per_session_price, status, payment{tran_ref, order_id}). 404 invalid package/shop. 422 outside availability window / amount short. 500 settle failure (nothing created, safe to retry)."
          }
        },
        {
          "name": "My packages",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/my-packages",
              "host": ["{{base_url}}"],
              "path": ["my-packages"]
            },
            "description": "The authenticated customer's OWNED entitlements — active, expired, exhausted, and cancelled (filter client-side by `status`, same idiom as the booking list). ONE query, eager-loaded, no N+1.\n\n200 -> data: array of { id, subscription_package_id, package_name, package_name_ar, sessions_total, sessions_remaining, expiry_date, purchase_date (unix ts), status ('active'|'expired'|'exhausted'|'cancelled'), shop:{id,name,image} }.\n\nDrive the \"My Packages\" screen: card per entitlement, progress bar sessions_remaining/sessions_total, greyed out when status != 'active'."
          }
        },
        {
          "name": "Redeemable entitlements for a shop+service",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/subscription-package/redeemable?shop_id={{shop_id}}&service_id={{service_id}}",
              "host": ["{{base_url}}"],
              "path": ["subscription-package", "redeemable"],
              "query": [
                { "key": "shop_id", "value": "{{shop_id}}", "description": "int, required" },
                { "key": "service_id", "value": "{{service_id}}", "description": "int, required — a shop_service id" }
              ]
            },
            "description": "Which of the customer's OWN entitlements can be redeemed RIGHT NOW for `service_id` at `shop_id` — call this from the checkout/service-picker screen to offer \"use a package session\" as a payment option instead of Paymob.\n\nAn entitlement qualifies when: owned by this customer, sold by this shop, status=active, sessions_remaining > 0, not past expiry_date, AND the package's included-services list contains service_id.\n\n200 -> data: array of { id, subscription_package_id, package_name, package_name_ar, sessions_total, sessions_remaining, expiry_date, per_session_price }. Empty array is a normal/expected result (no eligible entitlement) — the UI should silently fall back to the normal Paymob checkout, not show an error."
          }
        },
        {
          "name": "Redeem a session (book with a package)",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/subscription-package/redeem",
              "host": ["{{base_url}}"],
              "path": ["subscription-package", "redeem"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "entitlement_id", "value": "{{entitlement_id}}", "description": "int, required — from GET redeemable." },
                { "key": "service_id", "value": "{{service_id}}", "description": "int, required — a shop_service id included in the package." },
                { "key": "agent_id", "value": "{{agent_id}}", "description": "int, required — the specialist performing the service." },
                { "key": "booking_date", "value": "2026-08-05", "description": "string, required, Y-m-d." },
                { "key": "from_hour", "value": "10:00", "description": "string, required, H:i." },
                { "key": "to_hour", "value": "11:00", "description": "string, required, H:i." }
              ]
            },
            "description": "NOT Paymob-gated — skips the payment gateway entirely (already pre-paid at purchase time). Books `service_id` with `agent_id` at the given slot and atomically decrements `sessions_remaining` on `entitlement_id` by 1 (flips to status='exhausted' at 0). Re-validates ownership, service inclusion, specialist eligibility (must be capable of the service AND, if the package restricts specialists, on that allow-list), and slot availability (same FOR UPDATE conflict lock the normal book flow uses) — all inside one transaction; on ANY failure nothing is created and no session is consumed.\n\nThe resulting booking: status=SCHEDULED (2) immediately (no payment pending), payment_mode='package', package_entitlement_id set, amount_collected/balance_due/deposit=0 (nothing collected online — pre-paid), sub_amount/vat/total_amount kept at the service's real value for reporting parity with a normal booking.\n\n201 -> data: { booking:{id,status,booking_date,from_hour,to_hour,agent_id,shop_id,total_amount,payment_mode}, entitlement:{id,sessions_total,sessions_remaining,status} }. 404 invalid entitlement/service/agent. 409 slot no longer available. 422 not redeemable (no sessions/expired), service not in package, specialist can't perform it or isn't eligible."
          }
        },
        {
          "name": "[MODIFIED] Shop view — subscription_packages key",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/shops/{{shop_id}}",
              "host": ["{{base_url}}"],
              "path": ["shops", "{{shop_id}}"]
            },
            "description": "MODIFIED (additive only): the existing GET /shops/:id shop-page endpoint now returns ONE new top-level key, `subscription_packages` — every existing key/shape is byte-identical to before (verified: `$shop->toArray()` unchanged, key merged on afterward). Mobile clients that don't read the new key are 100% unaffected.\n\n`subscription_packages`: array of the shop's ACTIVE, non-hidden, in-availability-window (start_date/end_date) packages: { id, name, name_ar, image, price, per_session_price, sessions, validity_days, services:[{id,name}] }. Render these as \"buy a package\" cards on the shop page; tapping one starts the purchase flow above.\n\nSee the 'Deals & Promotions' folder for this same endpoint's OTHER new key, `active_deals` (BEA-009)."
          }
        },
        {
          "name": "[MODIFIED] Cancel booking — package session reinstatement",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/booking/update-status",
              "host": ["{{base_url}}"],
              "path": ["booking", "update-status"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "booking_id", "value": "{{booking_id}}", "description": "int, required." },
                { "key": "status", "value": "5", "description": "int, required. 5 = Booking::STATUS_CANCELED (the value that triggers this behaviour)." },
                { "key": "reason", "value": "Change of plans", "description": "string, required when status=5." }
              ]
            },
            "description": "MODIFIED (additive branch only — request/response shape unchanged, every non-package booking's cancel path is untouched): when the booking being cancelled carries `package_entitlement_id` (i.e. it was created via /subscription-package/redeem), cancelling it inside the shop's FULL-REFUND window (same hours-before-appointment policy a normally-paid booking's cash refund uses) now REINSTATES the consumed session on the entitlement (sessions_remaining += 1, status flips back to 'active' if it had gone 'exhausted') instead of a cash refund — there is no Payment row to refund for a package-redeemed booking. Outside the full-refund window, no reinstatement happens (same as no cash refund outside that window for a normal booking). This is best-effort and never blocks the cancellation itself from completing.\n\n200 -> data: { MESSAGE, refund: null-or-refund_type }. `refund` stays null for a package booking (no Payment row) regardless of whether the session was reinstated — check GET /my-packages afterward to see the updated sessions_remaining."
          }
        }
      ]
    },
    {
      "name": "Deals & Promotions (BEA-009)",
      "description": "Structured 'Deal' feed over the existing promo_code table (parity target: demo Discover.tsx deals tab) — replaces the old image-only Ads banner as the primary discovery surface for promos, and adds shop-scoped + auto-apply variants. The underlying booking-create promo enforcement (BookingForm) is unchanged in REQUEST SHAPE, only in validation depth (see the modified item below).",
      "item": [
        {
          "name": "Deals for you (global feed)",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/shops/deals",
              "host": ["{{base_url}}"],
              "path": ["shops", "deals"]
            },
            "description": "Auth optional. Cross-shop 'Deals for you' feed: ACTIVE, non-expired, cap-not-reached promo codes — platform-wide (shop_id=NULL) codes plus shop-scoped codes belonging to an ACTIVE shop in the caller's data world (demo vs live, keyed off the caller's is_demo; unauthenticated defaults to live). Closes a scoping leak the old Ads feed had.\n\n200 -> data: array of Deal { id, code, discount_type:'percentage'|'fixed', discount_value, usage_cap (null=unlimited), usage_count, remaining_uses (null when uncapped), expiry, shop: null-or-{shop_id,shop_name,image} }."
          }
        },
        {
          "name": "Applicable deals for a shop + cart",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/shops/{{shop_id}}/applicable-deals?service_ids={{service_id}}",
              "host": ["{{base_url}}"],
              "path": ["shops", "{{shop_id}}", "applicable-deals"],
              "query": [
                { "key": "service_ids", "value": "{{service_id}}", "description": "comma-separated shop_service ids, optional — the candidate cart. Non-numeric/blank segments are dropped, not rejected." }
              ]
            },
            "description": "Auth optional (unauthenticated -> applicable/applicable_reason computed with customerId=null, which skips per-customer-cap and first-time-only checks — only code-level checks apply). Scoped to ONE shop; for each currently-visible deal (platform-wide + this shop's own codes) reports whether it is redeemable RIGHT NOW for the caller via the full PromoCodeService::validate() (status/window/total-cap/service-scope/per-customer-cap/first-time-only) — not just \"does it exist\".\n\nUse for checkout auto-apply: call with the cart's service_ids and auto-select the first `applicable:true` deal, or show `applicable_reason` for a greyed-out ineligible deal.\n\n200 -> data: array of Deal (same shape as GET /shops/deals) + applicable:bool, applicable_reason:string|null.\n\nThis is a display/auto-apply HINT ONLY — BookingForm::save() at actual checkout re-validates with the real authenticated customer; a deal shown as applicable here can still be rejected at booking time (e.g. a race on the usage cap)."
          }
        },
        {
          "name": "[MODIFIED] Shop view — active_deals key",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/shops/{{shop_id}}",
              "host": ["{{base_url}}"],
              "path": ["shops", "{{shop_id}}"]
            },
            "description": "MODIFIED (additive only): the existing GET /shops/:id shop-page endpoint now returns ONE new top-level key, `active_deals` — every existing key/shape is byte-identical to before. This shop's own shop-scoped ACTIVE/visible promo codes plus platform-wide (null-shop) ones — no customer/cart context (no `applicable` flag; that's only on the /applicable-deals variant). Same Deal shape as GET /shops/deals.\n\nSee the 'Subscription Packages' folder for this same endpoint's OTHER new key, `subscription_packages` (BEA-008)."
          }
        },
        {
          "name": "[MODIFIED] Book (promo enforcement re-validated)",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/booking/book",
              "host": ["{{base_url}}"],
              "path": ["booking", "book"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "BookingForm[booking_id]", "value": "123", "description": "int, required — the NEW/draft booking created by preparing-booking / schedule-booking." },
                { "key": "BookingForm[promo_code]", "value": "SUMMER10", "description": "string, optional — UNCHANGED field name/shape. Behaviour-only change below." }
              ]
            },
            "description": "MODIFIED (behaviour only — request/response shape is 100% unchanged, existing mobile clients need no changes): `BookingForm`'s promo-code handling now delegates to the SAME `PromoCodeService::validate()` used by the new Deals endpoints, instead of the old ad-hoc expiry/usage check. A promo_code that fails validation (wrong shop, service not in scope, per-customer cap hit, not-first-time-only, expired/inactive/global-cap hit) is silently ignored (no discount applied, booking still succeeds) — same fail-open behaviour as before, just with more validation rules now enforced consistently with the Deals feed. No new request fields; the app should call GET /shops/:id/applicable-deals BEFORE this to know in advance whether a code will actually apply, since this endpoint gives no separate 'promo rejected' signal beyond the resulting discount being 0."
          }
        }
      ]
    },
    {
      "name": "Group Bookings (BEA-011)",
      "description": "Multi-guest 'party' bookings for the customer app — one lead booker organises N participants (each with their own specialist/services) for a shared start time, paid together with ONE transaction (or pay-on-visit). Every child booking shares customer_id = the lead booker (BR-G02) and is IDOR-scoped: a customer can only ever see/act on their own parties.",
      "item": [
        {
          "name": "Create a group booking",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" }
            ],
            "url": {
              "raw": "{{base_url}}/group-booking/create",
              "host": ["{{base_url}}"],
              "path": ["group-booking", "create"]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"shop_id\": {{shop_id}},\n  \"appointment_date\": \"2026-08-05 10:00\",\n  \"payment_timing\": \"on_visit\",\n  \"participants\": [\n    {\n      \"name\": \"\",\n      \"specialist_id\": {{agent_id}},\n      \"service_ids\": [{{service_id}}],\n      \"is_organiser\": true\n    },\n    {\n      \"name\": \"My friend Sara\",\n      \"specialist_id\": null,\n      \"service_ids\": [{{service_id}}],\n      \"is_organiser\": false\n    }\n  ]\n}",
              "options": { "raw": { "language": "json" } }
            },
            "description": "shop_id (int, required, ACTIVE shop) · appointment_date (string, required, 'Y-m-d H:i', single shared start, must be in the future) · payment_timing (string, required: 'online'|'deposit'|'on_visit' — must be an enabled mode for the shop) · participants (array, required, 1..max_group_size, default cap 10 unless shop/commercial_config overrides):\n  name (string, optional — guest label, defaults to the lead booker's name)\n  specialist_id (int, optional — omit to auto-assign the first free capable specialist)\n  service_ids (int[], required, >=1 — shop_service ids of THIS shop)\n  is_organiser (bool, optional — the lead's own row; participant 0 defaults to organiser if none marked)\n\nEach specialist can only serve ONE guest in the party (shared start). Placement is validated per participant against the same conflict-lock the solo book flow uses.\n\nPayment-status semantics mirror the solo flow: 'online'/'deposit' -> every child left STATUS_SELECTED_NOT_PAID (pending) until POST /group-booking/pay settles the WHOLE group; 'on_visit' -> children confirmed (STATUS_SCHEDULED) immediately, balance due at the shop.\n\n201 -> data: shaped group (see GET view below). 404 shop not found/inactive. 422 too many guests / invalid date / invalid timing / a participant validation failure (payload includes `participant_index` naming which guest failed). 409 a specific specialist/slot conflict (message names the participant)."
          }
        },
        {
          "name": "View a group booking",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/group-booking/view/{{group_booking_id}}",
              "host": ["{{base_url}}"],
              "path": ["group-booking", "view", "{{group_booking_id}}"]
            },
            "description": "Lead-booker-scoped party summary. 404 if the party doesn't exist OR belongs to someone else (indistinguishable — no existence oracle / IDOR leak).\n\n200 -> data: { group_booking_id, appointment_date, party_size, value, outstanding, duration_min, status (Booking::STATUS_* int, or the string 'mixed' when children differ), children: [{ id, guest_label, is_group_organiser, specialist_id, specialist_name, service_ids, booking_date, from_hour, to_hour, status, payment_mode, total_amount, amount_collected, balance_due, refund_value }] }."
          }
        },
        {
          "name": "Cancel the whole party",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/group-booking/cancel-group",
              "host": ["{{base_url}}"],
              "path": ["group-booking", "cancel-group"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "group_booking_id", "value": "{{group_booking_id}}", "description": "string, required." }
              ]
            },
            "description": "Cancels EVERY child in the party as the lead booker. Standard per-booking CUSTOMER cancellation policy applies to each child (zone-based refunds through the canonical ledger — same hours-before-appointment full/partial/no-refund zones a solo cancel uses). 404 not found/not yours. 422 service-level error.\n\n200 -> data: the shaped group, post-cancel (same shape as GET view)."
          }
        },
        {
          "name": "Cancel one participant",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/group-booking/cancel-participant",
              "host": ["{{base_url}}"],
              "path": ["group-booking", "cancel-participant"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "group_booking_id", "value": "{{group_booking_id}}", "description": "string, required." },
                { "key": "booking_id", "value": "{{booking_id}}", "description": "int, required — one child's booking id." }
              ]
            },
            "description": "Cancels ONE guest; siblings untouched, same per-booking customer refund policy as above. GUARD: the LAST remaining active guest cannot be cancelled this way — cancel the whole party instead (422 'This is the last guest left in the party...'). 404 party/participant not found or not yours. 422 already closed out / last-guest guard / service error.\n\n200 -> data: the shaped group, post-cancel."
          }
        },
        {
          "name": "Pay for the group [Paymob-gated]",
          "request": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
            ],
            "url": {
              "raw": "{{base_url}}/group-booking/pay",
              "host": ["{{base_url}}"],
              "path": ["group-booking", "pay"]
            },
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                { "key": "group_booking_id", "value": "{{group_booking_id}}", "description": "string, required." },
                { "key": "invoice_id", "value": "PAYMOB_TRANSACTION_ID", "description": "string, required — the Paymob transaction id for ONE charge covering the whole group." },
                { "key": "integration_order_id", "value": "PAYMOB_ORDER_ID", "description": "string, optional." },
                { "key": "payment_mode", "value": "online", "description": "'online'|'deposit', optional — defaults to the mode chosen at create." }
              ]
            },
            "description": "PAYMOB-GATED. Client flow: sum the still-pending (STATUS_SELECTED_NOT_PAID) children's totals (or per-child deposit amounts in deposit mode) into ONE Paymob charge, complete the Paymob SDK checkout client-side, then POST here with the resulting `invoice_id` — exactly the same handshake as the solo booking pay flow and the package purchase above.\n\nServer verifies the transaction (must be Paid, HTTP 200) and rejects if amount_cents is short of the expected group total (422, WHOLE group fails together — no partial confirmation). On success: every pending child -> STATUS_SCHEDULED, ONE Payment(+Transaction) row is written for the whole group, attached to the ORGANISER child (the customer-of-record row; first child as fallback) — reconcile a group payment by that child's group_booking_id, or by tran_ref on any child (every child gets invoice_id stamped = tran_ref).\n\nIdempotent per `invoice_id` (FOR UPDATE lock on `payment.tran_ref`, UNIQUE index backs it): a replay (webhook race or app retry) returns success without re-running side effects. If there is nothing pending (already settled, or the party was created pay-on-visit), 409 unless the invoice_id already exists as a Payment (then 200 idempotent).\n\n200 -> data: the shaped group, post-settle."
          }
        }
      ]
    },
    {
      "name": "Specialist Wallet (BEA-010)",
      "description": "SPECIALIST_APP (agent) surfaces. Uses the agent bearer token, not the customer one.",
      "auth": {
        "type": "bearer",
        "bearer": [ { "key": "token", "value": "{{agent_token}}", "type": "string" } ]
      },
      "item": [
        {
          "name": "Wages summary",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/agent/wallet/wages?date_from=2026-07-01&date_to=2026-07-31",
              "host": ["{{base_url}}"],
              "path": ["agent", "wallet", "wages"],
              "query": [
                { "key": "date_from", "value": "2026-07-01", "description": "Y-m-d, optional — defaults to the first day of the current calendar month." },
                { "key": "date_to", "value": "2026-07-31", "description": "Y-m-d, optional — defaults to the last day of the current calendar month." }
              ]
            },
            "auth": {
              "type": "bearer",
              "bearer": [ { "key": "token", "value": "{{agent_token}}", "type": "string" } ]
            },
            "description": "The authenticated specialist's commission + tips summary for a date window (default = current month). Commission math is delegated to WageEngineService::commissionInWindow — the SAME public engine method the shop-portal Payroll tab uses, so any commission-basis change there applies here automatically with zero duplicated math. Tips are summed directly from `booking.specialist_tip` (the authoritative tip column) over STATUS_COMPLETED bookings whose `completed_at` falls in the window.\n\n200 -> data: { wage_type ('fixed'|'commission'|'both'), pay_cycle ('weekly'|'biweekly'|'monthly'), fixed_salary, commission_pct, service_commission_earned, tips_earned, total_earnings, fixed_excluded_reason (string|null — WHY fixed_salary was NOT folded into total_earnings: commission-only specialist, or a non-monthly pay cycle inside this arbitrary window), period:{from,to} }.\n\nfixed_salary is only added into total_earnings for wage_type != 'commission' AND pay_cycle == 'monthly' — a fixed salary is a whole-month figure and would misstate a weekly/biweekly cycle sliced into an arbitrary reporting window, so it's surfaced as 0 with `fixed_excluded_reason` explaining why instead. 404 caller is not an active agent, or has no wage profile configured. 422 invalid date range."
          }
        }
      ]
    }
  ]
}
