openapi: 3.0.3
info:
  title: Aura POS API
  description: |
    Aura POS is a Cape Town hospitality point-of-sale platform (Kotlin Multiplatform terminal + Ktor server).
    This document covers every HTTP route mounted by the server, grouped by domain.

    ## Auth
    All routes are namespaced under `/api/v1` and use JWT bearer authentication, except the routes explicitly
    marked **Public** below (health checks, digital receipts, customer self-order QR, and inbound webhooks).
    Obtain a token with `POST /api/v1/auth/login`, then send it as `Authorization: Bearer <accessToken>`.

    Endpoints marked **Manager-gated** additionally require the caller's JWT `role` claim to be one of
    `ADMIN`, `SUPER_ADMIN`, or `MANAGER` — otherwise the server returns `403 forbidden` with error code
    `manager_required`.

    ## Sandbox
    Try every endpoint interactively at [`/docs`](/docs) (Swagger UI). Demo credentials:
    `manager@aurapos.co.za` / `Aura-Pos123!` / tenant `demo-cape-town`.

    ## Rate limits
    A handful of buckets are enforced server-side (see `x-rate-limit` vendor extension on each affected
    operation): `login` 10/min, `refresh` 30/min, `sync` 600/min, `qr` 600/min, `webhook` 1000/min,
    `booking` 60/min (per client IP, and shared with public click-and-collect), `platformToken` 20/min
    (per client IP). Exceeding a bucket returns `429 Too Many Requests` with a `Retry-After` header.

    ## Errors
    Failures use a uniform JSON body (see `ErrorBody`): `{ "error": "<code>", "message": "<detail>",
    "correlationId": "<id?>" }`. Business-rule failures typically return `409` with a specific `error` code
    (e.g. `voucher_expired`, `order_already_paid`); validation failures return `400` with `error:
    "validation_error"` or a specific code; missing/invalid JWTs return `401`; role failures return `403`.
  version: "1.0.0"
  contact:
    name: Aura POS
servers:
  - url: /
    description: This server (the origin serving these docs)
  - url: http://localhost:8080
    description: Local sandbox
  - url: https://aura-929608668708.africa-south1.run.app
    description: Hosted (Cloud Run)
security:
  - bearerAuth: []
tags:
  - name: System
    description: Health and readiness probes. Public, unauthenticated.
  - name: Auth
    description: Login, token refresh, and the current-user profile.
  - name: Catalogue
    description: Categories, products, modifier groups, and tax rates. Reads are open to any authenticated staff member; mutations are manager-gated.
  - name: Staff
    description: Staff administration (PINs, roles, deactivation) and shift clock-in/clock-out. Administration routes are manager-gated.
  - name: Locations
    description: Locations / business profile (V16) — address, VAT number, receipt header/footer, and the branding logo. Mutations are manager-gated; logo bytes are public.
  - name: Floor & Tables
    description: Floor plan (tables, zones), seating, table transfer/merge, and reservations.
  - name: Orders
    description: Order lifecycle (create, add lines, checkout, fire to kitchen, split), plus voids/comps/discounts.
  - name: Kitchen
    description: Kitchen Display System (KDS) tickets — bump, rush, recall.
  - name: Tabs
    description: Bar tabs — open, pre-authorize, settle.
  - name: Payments
    description: Payment initiation against SA payment providers (Yoco, SnapScan, Zapper) and cash capture.
  - name: Refunds
    description: Manager-approved refunds against a settled transaction.
  - name: Webhooks
    description: Public, HMAC-verified inbound webhooks from payment providers.
  - name: Sync
    description: Offline-first sync engine — push queued mutations, pull server changes since a cursor, and a WebSocket broadcast channel.
  - name: Reports
    description: Dashboard, shift X/Z reports, VAT201, heatmap, server performance, and audit logs (discounts/voids/comps). Includes the Aura-to-Ledgr journal bridge.
  - name: Loyalty & Vouchers
    description: Discount vouchers (manual and birthday) and redemption.
  - name: Gift Cards
    description: Issue, look up, and redeem in-store gift card credit.
  - name: House Accounts
    description: B2B credit accounts (guesthouses, corporates) — charge and settle postings.
  - name: Inventory
    description: 86 list (out-of-stock flags), wastage logging, low-stock alerts, and manual stock adjustments.
  - name: Suppliers & Purchasing
    description: Suppliers and purchase orders, including goods receiving into stock.
  - name: Pricing
    description: Time-based pricing rules (happy hour, lunch specials, day-of-week promos).
  - name: Click & Collect
    description: >-
      Online ordering for in-store collection (V34). Stock is RESERVED, never depleted, so on-hand
      stays true and `available = on_hand - reserved`. Unpaid holds expire or a shop's availability
      drains away silently. The public endpoints need no JWT and are rate-limited.
  - name: Rota & Delivery
    description: >-
      Staff rota and third-party delivery ingestion (V32). A rota is a plan kept separate
      from attendance, costed at today's rate; delivery ingestion is idempotent per
      (platform, external id) and records gross, commission and payout separately.
  - name: Menus & Invoices
    description: >-
      Central menu publishing and customer invoices (V33). Menus are versioned so a publish
      is atomic; an issued invoice is immutable and corrected by credit note. A sale can only
      ever be invoiced once.
  - name: Saved Reports
    description: >-
      Saved report templates, scheduled email delivery, labour cost against sales, sales
      grouped by brand/supplier/category, and drilldown (V31). A template stores a date
      PRESET, never two dates, so a saved report follows the calendar instead of reporting
      the same month forever. Delivery is idempotent per period. Manager-gated except
      /reports/presets.
  - name: Commission
    description: >-
      Staff commission (V30). Schemes describe the arrangement; entries record what was
      earned, per line, with the rate frozen so a report can never disagree with a payslip.
      Accrual is not an endpoint — it happens inside settlement and reverses inside a refund.
      All routes are manager-gated, because commission is pay.
  - name: Marketing
    description: >-
      Customer segments, campaigns, lifecycle automations and consent (V29). Segments are
      rules evaluated on demand, never stored member lists. Every campaign recipient gets a
      row — suppressed ones carry the reason — so sent + failed + suppressed always accounts
      for the audience. Lifecycle messages send once per occurrence via a dedupe key. All
      writes are manager-gated.
  - name: Quotes & Special Orders
    description: >-
      Business promised but not yet rung up (V28). A quote is a priced offer that expires
      and may become a sale; a special order is goods brought in for one named customer,
      usually against a deposit. Quotes reserve no stock and hold no money; special-order
      deposits are captured payments on a real sale, so they count toward the shift that
      took them.
  - name: Waitlist
    description: Walk-in waitlist queue (F-09) — add, seat, remove.
  - name: Checklists
    description: Digital task checklists — manager-authored templates and per-day runs staff tick off. All routes require a JWT; template CRUD is manager-gated.
  - name: Customer QR
    description: Public self-order PWA endpoints served from a table QR code — no JWT required.
  - name: Booking
    description: Public guest online-booking API — a restaurant website takes reservations. No JWT; rate-limited (`booking` bucket).
  - name: Receipts
    description: Public, signed-link digital receipts (HTML).
paths:
  # ───────────────────────────── System ─────────────────────────────
  /health:
    get:
      tags: [System]
      summary: Liveness probe
      description: Public. Always returns `ok` if the process is up; does not check the database.
      security: []
      responses:
        "200":
          description: Server is alive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }
  /ready:
    get:
      tags: [System]
      summary: Readiness probe
      description: Public. Confirms the database is reachable with a trivial query.
      security: []
      responses:
        "200":
          description: Server and database are ready.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }
        "503":
          description: Database unreachable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }

  # ───────────────────────────── Auth ─────────────────────────────
  /api/v1/auth/login:
    post:
      tags: [Auth]
      summary: Log in with email/password and receive a JWT pair
      description: |
        Public. Rate limit bucket `login`: 10 requests/min per client.
        Demo sandbox credentials: `manager@aurapos.co.za` / `Aura-Pos123!` / tenant `demo-cape-town`.
      x-rate-limit: "login: 10/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "200":
          description: Authenticated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid credentials (`error:"invalid_credentials"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: The email signs in to more than one business — choose one and retry (`error:"tenant_required"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TenantChoiceResponse" }
        "403":
          description: Account deactivated (`error:"account_inactive"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/auth/refresh:
    post:
      tags: [Auth]
      summary: Exchange a refresh token for a new access/refresh token pair
      description: "Public. Rate limit bucket `refresh`: 30 requests/min per client."
      x-rate-limit: "refresh: 30/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefreshRequest" }
      responses:
        "200":
          description: New token pair issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401":
          description: Refresh token invalid, expired, or already consumed (`error:"invalid_refresh_token"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/auth/me:
    get:
      tags: [Auth]
      summary: Get the current authenticated user's profile
      responses:
        "200":
          description: Current user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: User not found (`error:"user_not_found"`) — e.g. deleted after token issue.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/auth/logout:
    post:
      tags: [Auth]
      summary: Revoke all refresh tokens for the current user
      responses:
        "204":
          description: Logged out — all refresh tokens for this user are revoked.
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ───────────────────────────── Licensing (V40/V72) ─────────────────────────────
  /api/v1/licence/entitlements:
    get:
      tags: [Auth]
      summary: The products this tenant is licensed to run
      description: |
        Aura is sold as three products — RESTAURANT, RETAIL and QR_ORDERING (V72). Entitlements also ride
        the access token as the `prd` claim (comma-separated codes), re-stamped on every refresh; this
        endpoint is the readable copy for UIs. Any authenticated staff may read it.
      responses:
        "200":
          description: The tenant's entitlements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntitlementsDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/licence/redeem:
    post:
      tags: [Auth]
      summary: Redeem a licence key (manager)
      description: |
        Replaces the tenant's product set with what the key grants and dates the term from redemption.
        Keys are normalised — case, spaces and dashes never decide whether a paying customer gets in.
        Each key redeems exactly once.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code: { type: string, description: "The licence key, e.g. AURA-7K2P-9QXM." }
              required: [code]
      responses:
        "200":
          description: Redeemed — the tenant's new entitlements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntitlementsDto" }
        "400":
          description: "`code_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "`licence_not_found` — also the answer for a typo, deliberately."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`licence_already_redeemed` — by this tenant or another."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "422":
          description: "`licence_grants_nothing` — a bad issue, not a bad customer."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Catalogue ─────────────────────────────
  /api/v1/catalogue/categories:
    get:
      tags: [Catalogue]
      summary: List categories for the tenant
      responses:
        "200":
          description: Categories, ordered by `sortOrder`.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CategoryDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Catalogue]
      summary: Upsert a category (Manager-gated)
      description: Upserts by `id` when supplied and owned by this tenant; otherwise inserts a new row. `409 id_conflict` if the `id` belongs to another tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CategoryUpsertRequest" }
      responses:
        "200":
          description: Updated existing category.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Created new category.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/catalogue/prep-stations:
    get:
      tags: [Catalogue]
      summary: List prep stations for the tenant
      description: KDS routing config (V12). Readable by any authed staff — the KDS station chips and the category/product editors need it.
      parameters:
        - name: includeInactive
          in: query
          description: When `true`, includes deactivated stations (back-office view). Defaults to active-only.
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Prep stations, ordered by `sortOrder`.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PrepStationDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Catalogue]
      summary: Upsert a prep station (Manager-gated)
      description: Upserts by `id` when supplied and owned by this tenant; otherwise inserts a new row. `409 id_conflict` if the `id` belongs to another tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PrepStationUpsertRequest" }
      responses:
        "200":
          description: Updated existing prep station.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Created new prep station.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/catalogue/products:
    get:
      tags: [Catalogue]
      summary: List products for the tenant
      parameters:
        - name: includeInactive
          in: query
          description: When `true`, includes deactivated products (back-office view). Defaults to active-only (POS terminal view).
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Products, ordered by `sortOrder`.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ProductDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Catalogue]
      summary: Upsert a product (Manager-gated)
      description: True upsert — inserts when `id` is absent/unknown, updates the existing row (same tenant) otherwise. The request's `active` field is honoured on every upsert.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductUpsertRequest" }
      responses:
        "200":
          description: Updated existing product.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Created new product.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/catalogue/products/{id}/activate:
    post:
      tags: [Catalogue]
      summary: Reactivate a product (Manager-gated)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Product reactivated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, active: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/catalogue/products/{id}/deactivate:
    post:
      tags: [Catalogue]
      summary: Deactivate a product (Manager-gated)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Product deactivated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, active: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/catalogue/products/{id}/variants:
    get:
      tags: [Catalogue]
      summary: List a product's variants (V18)
      parameters:
        - $ref: "#/components/parameters/PathId"
        - { name: includeInactive, in: query, required: false, schema: { type: boolean, default: false } }
      responses:
        "200":
          description: Active (or all) variants, sort-ordered.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/VariantDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Catalogue]
      summary: Upsert a variant (V18, Manager-gated)
      description: sku/sortOrder are keep-on-absent on update. Variants sync to terminals like any catalogue row.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VariantUpsertRequest" }
      responses:
        "200":
          description: Variant updated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Variant created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/catalogue/products/{id}/variants/{variantId}/retire:
    post:
      tags: [Catalogue]
      summary: Retire a variant (V18, Manager-gated)
      parameters:
        - $ref: "#/components/parameters/PathId"
        - { name: variantId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Variant retired (soft delete).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Unknown variant (`error:"variant_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/catalogue/labels:
    post:
      tags: [Catalogue]
      summary: Barcode label sheet PDF (V18, Manager-gated)
      description: |
        A4 sheet of Code 128 labels (3 per row): product name (+ variant), price, barcode, human-readable
        code. Encodes the variant SKU when a variantId is given, else the product barcode, else its SKU.
        Items with no code are skipped; `400 no_codes` when nothing printable remains. Capped at 1000 labels.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LabelSheetRequest" }
      responses:
        "200":
          description: The label sheet.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "400":
          description: no_items / no_codes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/catalogue/products/{id}/photo:
    get:
      tags: [Catalogue]
      summary: Product photo bytes (Public)
      description: "Public (no JWT) — the customer QR menu's `<img>` tags fetch this directly. Served with `Cache-Control: public, max-age=300`."
      security: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Raw image bytes in the stored mime type.
          content:
            image/png: { schema: { type: string, format: binary } }
            image/jpeg: { schema: { type: string, format: binary } }
            image/webp: { schema: { type: string, format: binary } }
        "404":
          description: Unknown product, or no photo stored (`error:"no_photo"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    post:
      tags: [Catalogue]
      summary: Upload a product photo (Manager-gated)
      description: |
        Stores the base64 image (decoded size ≤700KB; PNG/JPEG/WebP) and rewrites the product's `imageUrl`
        to the internal `/api/v1/catalogue/products/{id}/photo` path, bumping `sync_version` so terminals
        repull the product on the next sync.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImageUploadRequest" }
      responses:
        "200":
          description: Photo stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductPhotoResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/catalogue/products/{id}/photo/delete:
    post:
      tags: [Catalogue]
      summary: Remove the stored product photo (Manager-gated)
      description: Nulls the stored photo. `imageUrl` is cleared only when it points at the internal photo path — a venue-set external URL is kept.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Photo removed; `imageUrl` reflects what the product now holds (may be an external URL or null).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductPhotoResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/catalogue/products/{id}/modifier-groups:
    get:
      tags: [Catalogue]
      summary: List a product's attached modifier groups
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Active links, in sort order.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ProductModifierLinkDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Catalogue]
      summary: Attach a modifier group to a product (Manager-gated, idempotent)
      description: Re-attaching an existing or soft-deleted link revives it and updates its sortOrder.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductModifierLinkRequest" }
      responses:
        "201":
          description: Link created (200 when an existing link was updated/revived).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductModifierLinkDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/catalogue/products/{id}/modifier-groups/{groupId}:
    delete:
      tags: [Catalogue]
      summary: Detach a modifier group from a product (Manager-gated)
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: Link soft-deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No active link between this product and group (`error:"link_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/catalogue/modifier-groups:
    get:
      tags: [Catalogue]
      summary: List modifier groups (with their options) for the tenant
      responses:
        "200":
          description: Modifier groups, ordered by `sortOrder`, each with its active options.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ModifierGroupDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Catalogue]
      summary: Upsert a modifier group and atomically replace its option set (Manager-gated)
      description: Options present in the request are upserted (by `id` when given); options previously on the group but missing from the request are soft-deleted so `sync pull` propagates the removal.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModifierGroupUpsertRequest" }
      responses:
        "200":
          description: Updated existing group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModifierGroupDto" }
        "201":
          description: Created new group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModifierGroupDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/catalogue/tax-rates:
    get:
      tags: [Catalogue]
      summary: List VAT/tax rates for the tenant
      responses:
        "200":
          description: Tax rates.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/TaxRateDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Catalogue]
      summary: Upsert a tax rate (Manager-gated)
      description: "`(tenant_id, code)` is unique — a duplicate `code` returns `409 duplicate_code`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaxRateUpsertRequest" }
      responses:
        "200":
          description: Updated existing rate.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Created new rate.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"

  # ───────────────────────────── Staff ─────────────────────────────
  /api/v1/staff:
    get:
      tags: [Staff]
      summary: List all staff for the tenant (Manager-gated)
      description: Includes inactive staff; check the `active` flag per row.
      responses:
        "200":
          description: Staff list, ordered by name.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/StaffDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Staff]
      summary: Create a staff member (Manager-gated)
      description: PIN is stored as a PBKDF2 hash (`pbkdf2$<iters>$<salt>$<hash>`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffCreateRequest" }
      responses:
        "201":
          description: Staff member created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StaffDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/staff/{id}:
    post:
      tags: [Staff]
      summary: Partially update a staff member (Manager-gated)
      description: Only fields present in the body change. `active:false` deactivates — the staffer loses POS access once terminals sync.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffUpdateRequest" }
      responses:
        "200":
          description: Updated staff member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StaffDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/staff/{id}/reset-pin:
    post:
      tags: [Staff]
      summary: Reset a staff member's PIN (Manager-gated)
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffResetPinRequest" }
      responses:
        "200":
          description: PIN reset.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, pinReset: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/shifts/clock-in:
    post:
      tags: [Staff]
      summary: Clock in (open a shift)
      responses:
        "201":
          description: Shift opened.
          content:
            application/json:
              schema: { type: object, properties: { shiftId: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Staffer already has an open shift (`error:"shift_already_open"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClockInRequest" }
  /api/v1/shifts/clock-out:
    post:
      tags: [Staff]
      summary: Clock out (close a shift)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClockOutRequest" }
      responses:
        "204":
          description: Shift closed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/shifts/{id}/close:
    post:
      tags: [Staff]
      summary: Force clock-out any open shift by id (Manager-gated)
      description: |
        Closes ANY open shift for the tenant — for staff who left without clocking out. Same close
        semantics as the self `/shifts/clock-out`. The body is optional; when `closingFloatCents` is
        absent the stored value is left untouched.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShiftCloseRequest" }
      responses:
        "200":
          description: Shift closed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShiftCloseResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Unknown shift (`error:"shift_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: Shift is not OPEN (`error:"shift_already_closed"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/shifts/open:
    get:
      tags: [Staff]
      summary: List currently open shifts for the tenant
      responses:
        "200":
          description: Open shifts.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/OpenShiftDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/shifts/{id}/movements:
    post:
      tags: [Staff]
      summary: Record a register cash movement (V17)
      description: |
        Reason-coded drawer event against an OPEN shift: `PAID_IN` | `PAID_OUT` | `NO_SALE`.
        Amounts are positive; direction comes from the type. `NO_SALE` must carry amount 0
        (audit trail for opening the drawer without a sale — the physical kick happens on the
        terminal). Movements feed the X/Z expected-cash line.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CashMovementRequest" }
      responses:
        "201":
          description: Movement recorded.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          description: Invalid type/amount/reason (`invalid_movement_type`, `invalid_amount`, `reason_required`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown shift (`error:"shift_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: Shift is not OPEN (`error:"shift_not_open"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    get:
      tags: [Staff]
      summary: List a shift's cash movements (oldest first)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Cash movements for the shift.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CashMovementDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/shifts/history:
    get:
      tags: [Staff]
      summary: Closed-register history (V17)
      description: |
        CLOSED shifts newest-first with the closure numbers persisted at Z-report time
        (expected/counted/variance — null on shifts closed before a Z was run) plus per-shift
        paid-in/paid-out totals.
      parameters:
        - { name: locationId, in: query, required: false, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: integer, format: int64 }, description: "clockOutAt ≥ from (epoch millis)" }
        - { name: until, in: query, required: false, schema: { type: integer, format: int64 }, description: "clockOutAt ≤ until (epoch millis)" }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 60, maximum: 200 } }
      responses:
        "200":
          description: Closed shifts with closure numbers.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ShiftSummaryDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ───────────────────────────── Locations ─────────────────────────────
  /api/v1/locations:
    get:
      tags: [Locations]
      summary: List active locations for the tenant
      responses:
        "200":
          description: Active locations with business-profile fields (receipt header/footer, `hasLogo`).
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/LocationDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Locations]
      summary: Upsert a location (Manager-gated)
      description: |
        Inserts when `id` is absent/unknown, updates otherwise. Optional fields are keep-on-absent on
        update (null leaves the stored value alone); on insert `timezone`/`currency` default to
        `Africa/Johannesburg`/`ZAR`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LocationUpsertRequest" }
      responses:
        "200":
          description: Upserted.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/locations/{id}/logo:
    get:
      tags: [Locations]
      summary: Location logo bytes (Public)
      description: "Public (no JWT) — digital receipts and the QR ordering surface render the branding logo directly. Served with `Cache-Control: public, max-age=300`."
      security: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Raw image bytes in the stored mime type.
          content:
            image/png: { schema: { type: string, format: binary } }
            image/jpeg: { schema: { type: string, format: binary } }
            image/webp: { schema: { type: string, format: binary } }
        "404":
          description: Unknown location, or no logo stored (`error:"no_logo"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    post:
      tags: [Locations]
      summary: Upload the location's branding logo (Manager-gated)
      description: Stores the base64 image (decoded size ≤300KB; PNG/JPEG/WebP) used on printed/digital receipts.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImageUploadRequest" }
      responses:
        "200":
          description: Logo stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LocationLogoResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/locations/{id}/logo/delete:
    post:
      tags: [Locations]
      summary: Remove the stored location logo (Manager-gated)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Logo removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LocationLogoResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ───────────────────────────── Floor & Tables ─────────────────────────────
  /api/v1/tables:
    get:
      tags: [Floor & Tables]
      summary: List floor tables
      parameters:
        - name: includeRetired
          in: query
          description: When `true`, includes soft-deleted (retired) tables.
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Tables for the tenant.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/TableDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Floor & Tables]
      summary: Upsert a floor table (Manager-gated)
      description: Updates only touch layout fields (label/seats/position/shape) — live seating state (status/guestCount/currentOrderId) is never clobbered.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TableUpsertRequest" }
      responses:
        "200":
          description: Updated existing table.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Created new table.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/tables/{id}/retire:
    post:
      tags: [Floor & Tables]
      summary: Soft-delete (retire) a table (Manager-gated)
      description: Vanishes from the POS floor but stays referenced by historical transactions. Refuses while occupied.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Table retired.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, retired: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Table has an open order (`error:"table_has_open_order"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/tables/{id}/seat:
    post:
      tags: [Floor & Tables]
      summary: Seat a party at a table
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TableSeatRequest" }
      responses:
        "200":
          description: Table marked OCCUPIED.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/tables/{id}/clear:
    post:
      tags: [Floor & Tables]
      summary: Clear a table back to AVAILABLE
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Table cleared.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/tables/{id}/transfer:
    post:
      tags: [Floor & Tables]
      summary: Move the open order from this table to another table
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TableTransferRequest" }
      responses:
        "200":
          description: Order moved.
          content:
            application/json:
              schema:
                type: object
                properties: { from: { type: string }, to: { type: string }, orderId: { type: string } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/tables/{id}/merge:
    post:
      tags: [Floor & Tables]
      summary: Merge this table's open order into another table's open order
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TableMergeRequest" }
      responses:
        "200":
          description: Orders merged; source table freed.
          content:
            application/json:
              schema:
                type: object
                properties: { merged: { type: string }, into: { type: string } }
        "400":
          description: Both tables need an open order (`error:"both_tables_need_open_orders"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/zones:
    get:
      tags: [Floor & Tables]
      summary: List floor zones
      responses:
        "200":
          description: Zones, ordered by `sortOrder`.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ZoneDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Floor & Tables]
      summary: Upsert a floor zone (Manager-gated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ZoneUpsertRequest" }
      responses:
        "200":
          description: Updated existing zone.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "201":
          description: Created new zone.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/reservations:
    get:
      tags: [Floor & Tables]
      summary: List reservations in a time window
      description: Excludes WAITLISTED (walk-in queue) and removed (CANCELLED/NOSHOW) entries.
      parameters:
        - name: from
          in: query
          description: Epoch millis, inclusive. Defaults to `0`.
          schema: { type: integer, format: int64 }
        - name: until
          in: query
          description: Epoch millis, exclusive. Defaults to unbounded.
          schema: { type: integer, format: int64 }
        - name: locationId
          in: query
          description: Optional server-side location filter.
          schema: { type: string }
      responses:
        "200":
          description: Reservations, soonest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReservationDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Floor & Tables]
      summary: Create or edit a reservation (upsert by id)
      description: |
        Posting an existing `id` edits the reservation. Validates `partySize >= 1`, a known
        `status` (PENDING | CONFIRMED | SEATED | NOSHOW | CANCELLED), and that `tableId`
        belongs to the tenant. Rejects (`409 reservation_conflict`) a reservation whose
        `[reservedForAt, reservedForAt + durationMinutes)` window overlaps another active
        reservation for the same table.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReservationUpsertRequest" }
      responses:
        "201":
          description: Reservation created (200 when an existing id was edited).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Table already reserved in that window (`error:"reservation_conflict"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/reservations/{id}/seat:
    post:
      tags: [Floor & Tables]
      summary: Mark a reservation SEATED
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Reservation seated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/reservations/{id}/cancel:
    post:
      tags: [Floor & Tables]
      summary: Cancel a reservation
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Reservation cancelled.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/reservations/{id}/no-show:
    post:
      tags: [Floor & Tables]
      summary: Mark a reservation NOSHOW
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Reservation marked no-show.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ───────────────────────────── Waitlist ─────────────────────────────
  /api/v1/waitlist:
    get:
      tags: [Waitlist]
      summary: Get the current walk-in queue for a location
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Waiting parties, first-come-first-served, with computed `position`/`waitedMinutes`/`quotedWaitMinutes`.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/WaitlistEntryDto" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Waitlist]
      summary: Add a walk-in party to the back of the queue
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaitlistAddRequest" }
      responses:
        "201":
          description: Party added to the waitlist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaitlistEntryDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/waitlist/{id}/seat:
    post:
      tags: [Waitlist]
      summary: Seat a waiting party at a table
      description: Marks the waitlist entry SEATED and seats the table through the same logic as `POST /tables/{id}/seat`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaitlistSeatRequest" }
      responses:
        "200":
          description: Party seated.
          content:
            application/json:
              schema:
                type: object
                properties: { id: { type: string }, status: { type: string }, tableId: { type: string } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Entry is no longer waiting (`error:"not_waitlisted"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/waitlist/{id}/remove:
    post:
      tags: [Waitlist]
      summary: Remove a waiting party (cancelled or no-show)
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaitlistRemoveRequest" }
      responses:
        "200":
          description: Entry updated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ───────────────────────────── Orders ─────────────────────────────
  /api/v1/orders:
    get:
      tags: [Orders]
      summary: List recent orders
      parameters:
        - name: from
          in: query
          description: Epoch millis; only orders opened at/after this time. Defaults to `0`. Result capped at 200 rows, newest first.
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: Orders.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/OrderDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Orders]
      summary: Create a new open order
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCreateRequest" }
      responses:
        "201":
          description: Order created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, number: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/orders/{id}:
    get:
      tags: [Orders]
      summary: Get an order by id
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/orders/{id}/lines:
    get:
      tags: [Orders]
      summary: Get every line on an order, plus its money header
      description: |
        Lets a second terminal see items another device fired onto the same open order (the POS ticket view is
        otherwise device-local). Any authenticated staff member; tenant-scoped like every other order read.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Order lines + header totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderLinesResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Orders]
      summary: Add a line item to an open order
      description: |
        The client's `unitPriceCents` is never trusted for pricing — the server re-prices from the product's
        base price plus any active price rule (happy hour, etc.); the client value is only a fallback for a
        product missing a price row. VAT is extracted at 15% inclusive for `STANDARD` vatType.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderLineRequest" }
      responses:
        "201":
          description: Line added.
          content:
            application/json:
              schema: { type: object, properties: { lineId: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Order is not OPEN (`error:"order_not_open"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/orders/{id}/checkout:
    post:
      tags: [Orders]
      summary: Checkout an order — record payments, post the sales journal, print/send the receipt
      description: |
        Runs the full settlement pipeline: payment + tip rows, stock decrement, loyalty accrual, POS-transaction
        roll-up to `PAID`, and the Ledgr sales journal, all in one transaction. Underpaid checkouts (payments +
        tips short of the order total) return `400`. Idempotency is per payment leg via `PaymentLeg.idempotencyKey`
        upstream at `/payments/*`; this endpoint itself is not separately idempotency-keyed.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutRequest" }
      responses:
        "200":
          description: Order settled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutResult" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/orders/{id}/layby:
    post:
      tags: [Orders]
      summary: Convert an OPEN order into a layby (V18)
      description: |
        Lightspeed layaway parity. Requires a customer (from the body or already attached) and a non-zero
        total. The optional deposit leg captures immediately and counts toward the taking register's X/Z
        (payments.shift_id). Errors: 400 customer_required / layby_needs_items / invalid_amount /
        overpays_balance, 409 already_layby / order_not_open.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LaybyOpenRequest" }
      responses:
        "200":
          description: Layby state after opening (+ deposit when given).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaybyPaymentResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/orders/{id}/layby-payment:
    post:
      tags: [Orders]
      summary: Take a layby installment (V18)
      description: |
        The leg captures immediately (CAPTURED, drawer-accurate via payments.shift_id). When the
        cumulative paid covers the total, the sale settles automatically through the normal checkout
        pipeline (stock, loyalty, journal) and `settled` comes back true. 400 overpays_balance guards
        against over-collection; 409 not_a_layby / order_not_open.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LaybyPaymentRequest" }
      responses:
        "200":
          description: Layby state after the installment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaybyPaymentResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /api/v1/orders/{id}/fire-course:
    post:
      tags: [Orders]
      summary: Fire a course (or all courses) to the kitchen
      description: One call → at most one ticket per (course × station). Re-firing the same course creates a new ticket (a separate round).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FireRequest" }
      responses:
        "201":
          description: Tickets created (or an empty list if nothing matched the course filter).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FireResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/orders/{id}/split:
    post:
      tags: [Orders]
      summary: Split the bill across guests
      description: "Split methods: `EVEN`, `BY_ITEM`, `BY_SEAT`, `CUSTOM`."
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SplitRequest" }
      responses:
        "201":
          description: Split computed and recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SplitResult" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Order already paid (`error:"order_already_paid"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/orders/{id}/customer:
    post:
      tags: [Orders]
      summary: Attach a customer to an OPEN order
      description: |
        Sets `customerId` on an already-open order — e.g. loyalty sign-up after the first course has fired.
        The order must still be `OPEN` and the customer must belong to the caller's tenant.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCustomerAttachRequest" }
      responses:
        "204":
          description: Customer attached.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Order or customer not found (`error:"order_not_found"` or `"customer_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: Order is not OPEN (`error:"order_not_open"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/voids:
    post:
      tags: [Orders]
      summary: Void an order or a single line (Manager-gated)
      description: Voids the whole transaction when `lineId` is omitted; otherwise flags just that line.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VoidRequest" }
      responses:
        "201":
          description: Void recorded.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/comps:
    post:
      tags: [Orders]
      summary: Comp a line item (Manager-gated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CompRequest" }
      responses:
        "201":
          description: Comp recorded.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/discounts:
    post:
      tags: [Orders]
      summary: Apply a structured discount to an open order (Manager-gated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DiscountApplyRequest" }
      responses:
        "201":
          description: Discount applied.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Order is not OPEN (`error:"order_not_open"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Kitchen ─────────────────────────────
  /api/v1/kitchen/stations:
    get:
      tags: [Kitchen]
      summary: List active prep stations for the tenant
      description: One filter chip per station on the KDS (V12). Any authed staff can read it.
      responses:
        "200":
          description: Active prep stations, ordered by `sortOrder`.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PrepStationDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/kitchen/tickets:
    get:
      tags: [Kitchen]
      summary: List KDS tickets for a location (kitchen board / service expo / recall list)
      description: >
        The default `board` view excludes `BUMPED`/`RECALLED` tickets and is ordered
        rush-first, then oldest-fired-first. `view=expo` powers the waiter/service side:
        every ticket not yet served — including bumped-at-the-pass tickets from the last
        60 minutes — plus just-served ones (10 minutes) so a mis-tap can be undone.
        `view=recall` lists tickets bumped in the last 30 minutes, newest first.
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
        - name: stationId
          in: query
          required: false
          description: "Filter to one configured prep station by id (V12)."
          schema: { type: string }
        - name: station
          in: query
          required: false
          description: "Legacy filter by station name/code (BAR | HOT | PASTRY | MAIN); superseded by `stationId`."
          schema: { type: string }
        - name: view
          in: query
          required: false
          description: "Which KDS face is asking (V15): board (default) | expo | recall."
          schema: { type: string, enum: [board, expo, recall] }
      responses:
        "200":
          description: Tickets.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/KitchenTicketDto" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/kitchen/tickets/{id}/bump:
    post:
      tags: [Kitchen]
      summary: Bump (complete) a ticket
      description: 'Optional JSON body `{"byStaffId": "..."}` attributes the bump to that (validated) staff member; defaults to the caller.'
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Ticket bumped.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/kitchen/tickets/{id}/ready:
    post:
      tags: [Kitchen]
      summary: Mark a ticket plated (FIRED → READY) and notify waitstaff
      description: >
        Stamps `readyAt` (first tap wins — re-tapping never resets the pass timer) and
        publishes a `kitchen.ready` "run this table" event on `/sync/ws`. The ticket
        stays on the kitchen board until bumped or served. Optional JSON body
        `{"byStaffId": "..."}` attributes it.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Ticket marked READY.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/kitchen/tickets/{id}/serve:
    post:
      tags: [Kitchen]
      summary: Confirm the food was delivered to the table (V15)
      description: >
        The waiter-side complement to bump. Stamps `servedAt`/`servedByStaffId`; if the
        kitchen never bumped the ticket, taking the food IS the bump, so the ticket is
        bumped off the board too (and SEQUENTIAL course auto-advance still fires).
        Idempotent — re-serving keeps the first stamp. Optional JSON body
        `{"byStaffId": "..."}` attributes the run.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Ticket served (response status SERVED).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The ticket is in the legacy RECALLED hide-state (`ticket_recalled`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/kitchen/tickets/{id}/unserve:
    post:
      tags: [Kitchen]
      summary: Undo a mis-tapped serve
      description: Clears `servedAt` — the ticket returns to the service run list (it stays BUMPED, off the kitchen board).
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Serve undone (status BUMPED).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No served ticket with this id for the caller's tenant (`ticket_not_served`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/kitchen/tickets/{id}/progress:
    post:
      tags: [Kitchen]
      summary: Set a ticket's shared per-item prep ticks (V15)
      description: >
        Full-state set (not a toggle) so concurrent KDS screens converge on
        last-write-wins. Indexes point into the ticket's `items` array; out-of-range
        and duplicate values are dropped. Broadcast to other devices as `kitchen.progress`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                doneIndexes: { type: array, items: { type: integer } }
      responses:
        "200":
          description: Ticks stored (echoes the cleaned index list).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, doneIndexes: { type: array, items: { type: integer } } } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/kitchen/tickets/{id}/rush:
    post:
      tags: [Kitchen]
      summary: Set or clear a ticket's rush (priority) flag
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: rush
          in: query
          required: false
          description: "true (default) to rush, false to un-rush. A JSON body {\"rush\": bool} also works."
          schema: { type: boolean }
      responses:
        "200":
          description: Rush flag updated to the requested value.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, rush: { type: boolean } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/kitchen/tickets/{id}/recall:
    post:
      tags: [Kitchen]
      summary: Recall a bumped ticket back to the board (BUMPED → FIRED)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Ticket back on the board (status FIRED).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, status: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No BUMPED ticket with this id for the caller's tenant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Tabs ─────────────────────────────
  /api/v1/tabs:
    get:
      tags: [Tabs]
      summary: List open/pre-billed tabs for a location
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Tabs.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/TabDto" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Tabs]
      summary: Open a new bar tab
      description: Creates the backing order and the tab record together.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TabOpenRequest" }
      responses:
        "201":
          description: Tab opened.
          content:
            application/json:
              schema:
                type: object
                properties: { tabId: { type: string }, orderId: { type: string } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/tabs/{id}/preauth:
    post:
      tags: [Tabs]
      summary: Pre-authorize a card against a tab (Yoco)
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TabPreauthRequest" }
      responses:
        "200":
          description: Pre-authorization created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  preauthAmountCents: { type: integer, format: int64 }
                  preauthRef: { type: string }
                  status: { type: string }
                  redirectUrl: { type: string, nullable: true }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: Yoco pre-auth failed (`error:"yoco_preauth_failed"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/tabs/{id}/settle:
    post:
      tags: [Tabs]
      summary: Settle a tab (final bill + tip) through the full checkout pipeline
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TabSettleRequest" }
      responses:
        "200":
          description: Tab settled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string }
                  tipCents: { type: integer, format: int64 }
                  totalCents: { type: integer, format: int64 }
                  paidCents: { type: integer, format: int64 }
        "400":
          description: Payments + tips don't cover the tab total.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Tab already settled (`error:"tab_already_settled"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Payments ─────────────────────────────
  /api/v1/payments/{id}:
    get:
      tags: [Payments]
      summary: Fetch a payment's status
      description: |
        Status poll for a pending provider payment — the terminal's safety net while it waits for the
        provider webhook to capture (browser tills have no WebSocket push channel). Tenant-scoped.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The payment's current status (`PENDING` | `CAPTURED` | `FAILED`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No such payment for this tenant (`error:"payment_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/payments/yoco/checkout:
    post:
      tags: [Payments]
      summary: Create a Yoco card checkout
      description: |
        Supports replay via `idempotencyKey`: a retried create with the same key returns the payment
        recorded by the first attempt instead of charging twice.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentRequest" }
      responses:
        "200":
          description: Checkout created (or idempotent replay of an existing one).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: Yoco unavailable (`error:"yoco_unavailable"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/payments/snapscan/create:
    post:
      tags: [Payments]
      summary: Create a SnapScan QR payment
      description: Supports replay via `idempotencyKey`, including recovering the original QR payload on a retried create.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentRequest" }
      responses:
        "200":
          description: Payment created (or idempotent replay).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: SnapScan unavailable (`error:"snapscan_unavailable"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/payments/zapper/create:
    post:
      tags: [Payments]
      summary: Create a Zapper QR payment
      description: Supports replay via `idempotencyKey`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentRequest" }
      responses:
        "200":
          description: Payment created (or idempotent replay).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: Zapper unavailable (`error:"zapper_unavailable"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/payments/cash:
    post:
      tags: [Payments]
      summary: Capture a cash payment
      description: Captures immediately (no provider round-trip) and returns the signed public digital-receipt link. Supports replay via `idempotencyKey`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentRequest" }
      responses:
        "200":
          description: Cash captured.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ───────────────────────────── Refunds ─────────────────────────────
  /api/v1/payments/refund:
    post:
      tags: [Refunds]
      summary: Refund a settled transaction, in full or in part (Manager-gated)
      description: |
        Three-phase refund so a hung provider can never pin a database connection: snapshot the original
        transaction/payments/lines/journal, reverse with each payment's provider, then post the reversing
        journal entry. Guards against double reversal — an already-`REFUNDED` original or an existing
        refund transaction is a `409`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundRequest" }
      responses:
        "200":
          description: Refund processed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RefundResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Already refunded, or refund exceeds the remaining balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Webhooks ─────────────────────────────
  /api/v1/webhooks/yoco:
    post:
      tags: [Webhooks]
      summary: Yoco payment webhook
      description: |
        Public. Standard-Webhooks (Svix) signature scheme: HMAC-SHA256 over
        `{webhook-id}.{webhook-timestamp}.{raw body}` with the base64 `whsec_` secret, matched against the
        space-separated `v1,<base64>` entries in `webhook-signature`, verified against the raw request body
        before any JSON parsing (timestamp replay window ±3 min). Deduped by the payload's event id — a
        replayed event of any type is acknowledged without reprocessing. Handles `payment.succeeded`
        (captures the matching payment by `payload.metadata.checkoutId`), `payment.failed` (fails it) and
        `refund.*` (stored as confirmation). Rate limit bucket `webhook`: 1000 requests/min.
      x-rate-limit: "webhook: 1000/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: "Raw Yoco event payload — verified as text before parsing." }
      responses:
        "200":
          description: Acknowledged (processed, or already-seen and skipped).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "400":
          description: Payload was not valid JSON.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "401":
          description: Signature mismatch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/webhooks/snapscan:
    post:
      tags: [Webhooks]
      summary: SnapScan payment webhook
      description: |
        Public. HMAC-SHA256 signature in the `x-snapscan-signature` header, hex-encoded. Deduped — a repeat
        capture event for an already-captured payment is a no-op. Rate limit bucket `webhook`: 1000 requests/min.
      x-rate-limit: "webhook: 1000/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: "Raw SnapScan event payload — verified as text before parsing." }
      responses:
        "200":
          description: Acknowledged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "400":
          description: Payload was not valid JSON, or missing an id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "401":
          description: Signature mismatch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/webhooks/zapper:
    post:
      tags: [Webhooks]
      summary: Zapper payment webhook
      description: |
        Public. HMAC-SHA256 signature in the `x-zapper-signature` header, hex-encoded. If
        `ZAPPER_WEBHOOK_SECRET` is not configured, the endpoint rejects every request with `503` rather than
        accept unverified payment notifications. Rate limit bucket `webhook`: 1000 requests/min.
      x-rate-limit: "webhook: 1000/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: "Raw Zapper event payload — verified as text before parsing." }
      responses:
        "200":
          description: Acknowledged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "400":
          description: Payload was not valid JSON, or missing an id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "401":
          description: Signature mismatch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "503":
          description: Webhook secret not configured — provider disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAck" }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Sync ─────────────────────────────
  /api/v1/sync/push:
    post:
      tags: [Sync]
      summary: Push queued offline mutations from a terminal
      description: |
        Each mutation is idempotent by its own `id` (a retried push with the same id is a no-op, never a
        primary-key violation). A mutation that cannot yet be applied (e.g. a referenced row hasn't synced)
        is reported `accepted:false` so the client keeps it queued and retries later. Rate limit bucket
        `sync`: 600 requests/min.
      x-rate-limit: "sync: 600/min"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SyncPushRequest" }
      responses:
        "200":
          description: Per-mutation accept/reject results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SyncPushResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/sync/pull:
    get:
      tags: [Sync]
      summary: Pull server-side changes since a cursor
      description: "Cursor-based pull — pass the `nextCursor` from the previous response as `since` on the next call. Rate limit bucket `sync`: 600 requests/min."
      x-rate-limit: "sync: 600/min"
      parameters:
        - name: since
          in: query
          description: Cursor (sync version watermark). Defaults to `0` (full pull).
          schema: { type: integer, format: int64, default: 0 }
      responses:
        "200":
          description: Entities changed since the cursor, plus the next cursor to resume from.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SyncPullResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/sync/queue/count:
    get:
      tags: [Sync]
      summary: Get pending/failed counts for this tenant's sync inbox
      description: "A mutation is `failed` after 3 attempts. Rate limit bucket `sync`: 600 requests/min."
      x-rate-limit: "sync: 600/min"
      responses:
        "200":
          description: Queue counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SyncQueueCount" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/sync/ws:
    get:
      tags: [Sync]
      summary: Real-time event broadcast channel (WebSocket upgrade)
      description: |
        **This is a WebSocket endpoint, not a plain HTTP GET** — connect with a WebSocket client to
        `wss://<host>/api/v1/sync/ws` and this operation upgrades the connection. The tenant is taken from
        the JWT `tid` claim (pass the bearer token as usual during the handshake); the connection is refused
        if the claim is missing. The server pushes JSON-encoded `AuraEvent`s (e.g. `TableSeated`,
        `KitchenBumped`, `PaymentCaptured`) as they occur; inbound client frames are logged but otherwise
        ignored. Not subject to the `sync` rate-limit bucket (outside the `rateLimit` block) and not
        representable as a standard request/response pair — documented here for completeness.
      responses:
        "101":
          description: Switching Protocols — WebSocket connection established.

  # ───────────────────────────── Reports ─────────────────────────────
  /api/v1/reports/dashboard:
    get:
      tags: [Reports]
      summary: Revenue/covers/tips dashboard for a period
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: Dashboard data (JSON, or a CSV attachment with `format=csv`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardReport" }
            text/csv:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/x-report:
    get:
      tags: [Reports]
      summary: X-report (unlocked mid-shift snapshot) for a shift
      parameters:
        - name: shiftId
          in: query
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/CsvFormat"
      responses:
        "200":
          description: X-report (JSON, or a CSV attachment with `format=csv`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/XReport" }
            text/csv:
              schema: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/z-report:
    get:
      tags: [Reports]
      summary: Export a shift's Z figures as CSV (read-only)
      description: The shift's aggregates + cash-up numbers WITHOUT the POST side effects (no journal, no shift lock).
      parameters:
        - name: shiftId
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: CSV attachment.
          content:
            text/csv:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Reports]
      summary: Generate the Z-report and lock the shift
      description: Posts the closing journal entry and locks the shift (`zReportGenerated`). Fails with `409` if already generated for this shift.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ZReportRequest" }
      responses:
        "200":
          description: Z-report generated; shift locked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ZReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Z-report already generated for this shift (`error:"z_report_already_generated"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/reports/heatmap:
    get:
      tags: [Reports]
      summary: Day-of-week × hour revenue heatmap
      parameters:
        - name: days
          in: query
          description: Trailing window size in days. Defaults to `30`. Ignored when `from`+`until` are supplied.
          schema: { type: integer, default: 30 }
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
      responses:
        "200":
          description: Heatmap grid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeatmapReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/server-performance:
    get:
      tags: [Reports]
      summary: Per-server performance breakdown for a period
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
      responses:
        "200":
          description: Per-server rows, sorted by gross revenue descending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServerPerformanceReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/discounts:
    get:
      tags: [Reports]
      summary: Discount audit log for a period
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
      responses:
        "200":
          description: Discount log entries.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DiscountLogEntry" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/voids:
    get:
      tags: [Reports]
      summary: Void audit log for a period
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
      responses:
        "200":
          description: Void log entries.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/VoidLogEntry" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/comps:
    get:
      tags: [Reports]
      summary: Comp audit log for a period
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
      responses:
        "200":
          description: Comp log entries.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CompLogEntry" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/vat201:
    get:
      tags: [Reports]
      summary: SARS VAT201 return figures for an explicit date range
      parameters:
        - name: from
          in: query
          required: true
          description: Epoch millis, inclusive.
          schema: { type: integer, format: int64 }
        - name: until
          in: query
          required: true
          description: Epoch millis, exclusive.
          schema: { type: integer, format: int64 }
        - name: format
          in: query
          required: false
          description: "`csv` for a CSV attachment, `pdf` for the SARS-style VAT201 PDF; omit for JSON."
          schema: { type: string, enum: [csv, pdf] }
      responses:
        "200":
          description: VAT201 figures (JSON / CSV / PDF per `format`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Vat201Report" }
            text/csv:
              schema: { type: string }
            application/pdf:
              schema: { type: string, format: binary }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/menu-engineering:
    get:
      tags: [Reports]
      summary: Menu-engineering quadrant report (STAR/PLOWHORSE/PUZZLE/DOG)
      description: |
        Per active product that sold in the window: units sold, gross revenue,
        recipe-derived cost (waste-adjusted, like the sale stock deduction),
        margin + margin %, popularity rank, and a Kasavana-Smith quadrant using
        a median split of units sold × per-unit contribution margin.
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
      responses:
        "200":
          description: Menu-engineering report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MenuEngineeringReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/pacing:
    get:
      tags: [Reports]
      summary: Service-pacing report (Lightspeed Tempo equivalent)
      description: |
        Average and p90 for order→fire, fire→bump (kitchen time), and
        order→settle (table turn), from order + kitchen-ticket timestamps; plus
        the slowest stations/courses and a count of long-idle occupied tables.
        `availableMetrics`/`unavailableMetrics` report which timers could be
        computed — `seatedToFirstOrder` is unavailable because `tables.seated_at`
        is cleared at settlement.
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/LocationId"
        - $ref: "#/components/parameters/RangeFrom"
        - $ref: "#/components/parameters/RangeUntil"
        - name: idleMinutes
          in: query
          required: false
          description: "Occupied-table idle threshold in minutes. Defaults to 90."
          schema: { type: integer, default: 90 }
      responses:
        "200":
          description: Pacing report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PacingReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/products:
    get:
      tags: [Reports]
      summary: Sales by product (V18)
      description: Per-product quantity / revenue / recipe-aware cost / margin in the window, with category + brand for slicing. Comped lines count units at zero revenue; voided lines are dropped.
      parameters:
        - { name: period, in: query, required: false, schema: { type: string, enum: [today, yesterday, week, month, year] } }
        - { name: from, in: query, required: false, schema: { type: integer, format: int64 } }
        - { name: until, in: query, required: false, schema: { type: integer, format: int64 } }
        - { name: locationId, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Rows sorted by revenue descending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductsReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/customers:
    get:
      tags: [Reports]
      summary: Customer analytics (V18)
      description: Sales-with-customer share, unique/new/returning customers, average basket (overall vs with-customer), repeat rate, top 10 customers by revenue.
      parameters:
        - { name: period, in: query, required: false, schema: { type: string, enum: [today, yesterday, week, month, year] } }
        - { name: from, in: query, required: false, schema: { type: integer, format: int64 } }
        - { name: until, in: query, required: false, schema: { type: integer, format: int64 } }
        - { name: locationId, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomersReport" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/inventory-aging:
    get:
      tags: [Reports]
      summary: Aging ("dusty") inventory (V18)
      description: Counted stock with no sale in the last `days` days — on hand, stock value (cost × on hand), last sold, days since. Never-sold products carry null lastSoldAt.
      parameters:
        - { name: locationId, in: query, required: true, schema: { type: string } }
        - { name: days, in: query, required: false, schema: { type: integer, default: 30, minimum: 1, maximum: 3650 } }
      responses:
        "200":
          description: Rows sorted by stock value descending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryAgingReport" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/reports/accounting-export:
    get:
      tags: [Reports]
      summary: Journal-import CSV for Xero or Sage/Pastel
      description: |
        Generates a journal-import CSV from the double-entry ledger
        (journal_entries/journal_lines) posted in `[from, until)`. Xero emits a
        manual-journal layout (single signed Amount column, debit positive);
        Sage/Pastel emits a general-ledger layout (separate Debit/Credit
        columns). Revenue accounts map to the standard 15% VAT code; the VAT
        control account and everything else are untaxed.
      parameters:
        - name: format
          in: query
          required: false
          description: "`xero` (default) or `sage`."
          schema: { type: string, enum: [xero, sage], default: xero }
        - name: from
          in: query
          required: true
          description: Epoch millis, inclusive.
          schema: { type: integer, format: int64 }
        - name: until
          in: query
          required: true
          description: Epoch millis, exclusive.
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: CSV attachment in the requested provider's layout.
          content:
            text/csv:
              schema: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/migration/import:
    post:
      tags: [Reports]
      summary: Import a vendor CSV export (Manager-gated)
      description: |
        Body is the raw CSV text. `vendor` picks the column adapter
        (`lightspeed` | `tebi` | `pilot`). `mode=preview` (default) validates and
        returns a PreviewReport without writing; `mode=commit` find-or-creates
        categories per tenant and inserts every valid product row.
      parameters:
        - name: vendor
          in: query
          required: true
          schema: { type: string, enum: [lightspeed, tebi, pilot] }
        - name: mode
          in: query
          required: false
          schema: { type: string, enum: [preview, commit], default: preview }
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
          text/plain:
            schema: { type: string }
      responses:
        "200":
          description: PreviewReport (mode=preview) or CommitReport (mode=commit).
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/ledgr/generate-journals:
    post:
      tags: [Reports]
      summary: Backfill Ledgr sales journals for transactions that arrived without one (Manager-gated)
      description: |
        Aura → Ledgr bridge. `SalesService.checkout` already posts a `POS_SALE` journal for every server-side
        sale, so this is a backfill for transactions that arrived without one (e.g. offline sales materialised
        via `/sync/push`). Transactions that already carry a `POS_SALE` journal entry are skipped, not
        re-posted, to avoid double-counting revenue. An unbalanced computed entry is also skipped (logged), not
        posted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JournalGenerateRequest" }
      responses:
        "200":
          description: Journals generated (with a count of entries generated vs. skipped).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JournalGenerateResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  # ───────────────────────────── Loyalty & Vouchers ─────────────────────────────
  /api/v1/vouchers:
    get:
      tags: [Loyalty & Vouchers]
      summary: List vouchers for the tenant
      responses:
        "200":
          description: Vouchers, newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/VoucherDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Loyalty & Vouchers]
      summary: Create a voucher
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VoucherCreateRequest" }
      responses:
        "201":
          description: Voucher created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/vouchers/redeem:
    post:
      tags: [Loyalty & Vouchers]
      summary: Redeem a voucher against an open order (Manager-gated)
      description: Validates active/date-window/max-uses/min-spend before applying a `PERCENT` (basis points) or `FIXED` discount to the order.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VoucherRedeemRequest" }
      responses:
        "200":
          description: Voucher redeemed; discount applied.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VoucherRedeemResult" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Voucher or order not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "Voucher inactive/expired/not-yet-valid/exhausted, below min spend, or order not open (`error:` one of `voucher_inactive`, `voucher_not_yet_valid`, `voucher_expired`, `voucher_exhausted`, `below_min_spend`, `order_not_open`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/vouchers/{id}:
    post:
      tags: [Loyalty & Vouchers]
      summary: Edit a voucher (Manager-gated, BO-F27)
      description: "Partial update; keep-on-absent. `code` is immutable. Returns the updated voucher."
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VoucherUpdateRequest" }
      responses:
        "200":
          description: Updated voucher.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VoucherDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/vouchers/{id}/toggle:
    post:
      tags: [Loyalty & Vouchers]
      summary: Activate / deactivate a voucher (Manager-gated, BO-F27)
      description: "Flips the voucher's `active` flag and returns the updated row."
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Updated voucher.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VoucherDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/loyalty/birthday-vouchers:
    post:
      tags: [Loyalty & Vouchers]
      summary: Issue today's birthday vouchers (intended for a daily Cloud Scheduler trigger)
      description: Issues a 20%-off voucher (valid 30 days) for every customer whose stored birthday (MM-DD, Africa/Johannesburg) is today.
      responses:
        "200":
          description: Vouchers issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  issued: { type: integer }
                  voucherIds: { type: array, items: { type: string } }
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ───────────────────────────── Gift Cards ─────────────────────────────
  /api/v1/gift-cards:
    get:
      tags: [Gift Cards]
      summary: Paginated admin browse of issued gift cards (Manager-gated)
      parameters:
        - name: status
          in: query
          description: Filter by derived state.
          schema: { type: string, enum: [ACTIVE, REDEEMED, VOIDED, EXPIRED] }
        - name: q
          in: query
          description: Case-insensitive match on code or issued-to name/email/phone.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, format: int64, default: 0 }
      responses:
        "200":
          description: Page of gift cards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GiftCardPage" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Gift Cards]
      summary: Issue a new gift card
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GiftCardIssueRequest" }
      responses:
        "201":
          description: Gift card issued (includes the generated code).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GiftCardDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/gift-cards/{code}:
    get:
      tags: [Gift Cards]
      summary: Look up a gift card's balance and metadata by code
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Gift card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GiftCardDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Gift card not found (`error:"gift_card_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/gift-cards/redeem:
    post:
      tags: [Gift Cards]
      summary: Redeem a gift card against a transaction
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GiftCardRedeemRequest" }
      responses:
        "200":
          description: Redeemed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GiftCardRedeemResult" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Gift card not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: Card voided, expired, or insufficient balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── House Accounts ─────────────────────────────
  /api/v1/house-accounts:
    get:
      tags: [House Accounts]
      summary: List house accounts for the tenant
      responses:
        "200":
          description: Accounts, newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/HouseAccountDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [House Accounts]
      summary: Create a house account
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HouseAccountCreateRequest" }
      responses:
        "201":
          description: Account created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/house-accounts/charge:
    post:
      tags: [House Accounts]
      summary: Post a charge to a house account
      description: Enforces the account's credit limit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HouseAccountChargeRequest" }
      responses:
        "201":
          description: Charge posted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HouseAccountPostingDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Charge would exceed the account's credit limit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/house-accounts/settle:
    post:
      tags: [House Accounts]
      summary: Post a settlement (payment) against a house account
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HouseAccountSettlementRequest" }
      responses:
        "201":
          description: Settlement posted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HouseAccountPostingDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/house-accounts/{id}/postings:
    get:
      tags: [House Accounts]
      summary: Statement of postings for a house account
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Postings.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/HouseAccountPostingDto" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ───────────────────────────── Inventory ─────────────────────────────
  /api/v1/inventory/86:
    get:
      tags: [Inventory]
      summary: Current 86 list for a location
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Active 86 entries.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EightySixEntry" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Inventory]
      summary: 86 a product at a location (mark it unavailable)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EightySixRequest" }
      responses:
        "201":
          description: Product 86'd.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/inventory/86/clear:
    post:
      tags: [Inventory]
      summary: Clear a product from the 86 list (restock)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClearEightySixRequest" }
      responses:
        "204":
          description: Cleared.
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/inventory/wastage:
    post:
      tags: [Inventory]
      summary: Log wastage/spoilage
      description: Open to all authenticated staff (chefs log wastage directly); the acting user is always recorded even if `byStaffId` is omitted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WastageRequest" }
      responses:
        "201":
          description: Wastage recorded and stock decremented.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/inventory/low-stock:
    get:
      tags: [Inventory]
      summary: Products at or below their reorder point
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Low-stock entries.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/LowStockEntry" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/inventory/where-is:
    get:
      tags: [Inventory]
      summary: Which location holds this SKU (Manager-gated)
      description: >
        Answers "who has this SKU right now" across every location — the question that
        makes a transfer happen. Matches on product name, product SKU or variant SKU;
        a product match implies all of its variants. Variant-aware (V19).
      parameters:
        - name: query
          in: query
          required: true
          description: Product name, product SKU or variant SKU (case-insensitive, contains).
          schema: { type: string }
      responses:
        "200":
          description: Matching SKUs with their per-location on-hand.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/WhereIsEntry" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/inventory/replenishment:
    get:
      tags: [Inventory]
      summary: Velocity, cover days, suggested order and dead stock (Manager-gated)
      description: >
        Derives the rate of sale per SKU from the movement ledger and reports cover days —
        how long the shelf lasts at that observed rate — rather than tripping on a static
        reorder point. Only SALE movements count as demand; wastage and count corrections
        are excluded so they cannot inflate a reorder suggestion. `coverDays` is omitted
        when nothing sold, which is a different fact from "lasts forever" and drives the
        `deadStock` flag instead.
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
        - name: days
          in: query
          description: Observation window in days (1-365).
          schema: { type: integer, default: 30 }
        - name: leadTimeDays
          in: query
          description: Supplier lead time, used to size the suggested order.
          schema: { type: integer, default: 7 }
      responses:
        "200":
          description: One entry per stocked SKU, most urgent first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReplenishmentEntry" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/inventory/usage-variance:
    get:
      tags: [Inventory]
      summary: What sales explain vs what counting found (Manager-gated)
      description: >
        The shrinkage number. Compares consumption the sales account for (a SALE movement,
        recipe-exploded for composed dishes) against what counting the shelf corrected
        (a STOCK_COUNT movement). The gap is unexplained — over-pouring, unlogged spoilage
        or theft. Logged wastage is reported separately rather than netted off, because
        managed cost and mystery loss are different problems.
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
        - name: from
          in: query
          description: Period start, epoch millis. Defaults to 30 days ago.
          schema: { type: integer, format: int64 }
        - name: until
          in: query
          description: Period end, epoch millis. Defaults to now.
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: One entry per SKU with any usage or correction, worst loss first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/UsageVarianceEntry" } }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/recipes:
    get:
      tags: [Inventory]
      summary: Every recipe with its ingredient lines and costs
      description: >
        Recipes drive two behaviours that have shipped since V1: selling a dish deducts its
        ingredients, and an ingredient reaching zero takes the dish off sale everywhere.
        `perSaleUnits` on each line is what one sale actually deducts, after yield and waste.
      responses:
        "200":
          description: Recipes for this tenant.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Recipe" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Inventory]
      summary: Create or replace a product's recipe (Manager-gated)
      description: >
        Replace-not-patch: send the whole recipe and the server makes it true in one
        transaction. A product has at most one recipe, so the productId is the identity.
        `costCents` is always derived server-side from ingredient costs and is ignored if sent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecipeUpsertRequest" }
      responses:
        "200":
          description: Existing recipe replaced.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Recipe" }
        "201":
          description: Recipe created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Recipe" }
        "400":
          description: >
            empty_recipe · invalid_yield · invalid_waste_pct · invalid_quantity ·
            duplicate_ingredient · self_referencing_ingredient · unknown_product
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/recipes/{productId}:
    get:
      tags: [Inventory]
      summary: One product's recipe
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The recipe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Recipe" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: recipe_not_found
    delete:
      tags: [Inventory]
      summary: Remove a product's recipe (Manager-gated)
      description: >
        The dish stops deducting ingredients and stops being auto-86'd. Stock already
        recorded is unaffected.
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: Removed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: recipe_not_found
  /api/v1/inventory/adjust:
    post:
      tags: [Inventory]
      summary: Manual stock adjustment with a reason (Manager-gated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdjustmentRequest" }
      responses:
        "201":
          description: Adjustment recorded.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  # ───────────────────────────── Suppliers & Purchasing ─────────────────────────────
  # ───────────────────────── Stock transfers (V18) ─────────────────────────
  /api/v1/stock-transfers:
    get:
      tags: [Inventory]
      summary: List inter-location stock transfers (V18)
      parameters:
        - { name: locationId, in: query, required: false, schema: { type: string }, description: "Either side of the move" }
        - { name: status, in: query, required: false, schema: { type: string, enum: [DRAFT, IN_TRANSIT, RECEIVED, CANCELLED] } }
      responses:
        "200":
          description: Transfers, newest first (max 100), lines included.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/StockTransferDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Inventory]
      summary: Create a DRAFT transfer (V18, Manager-gated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StockTransferCreateRequest" }
      responses:
        "201":
          description: Draft created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, transferNumber: { type: string } } }
        "400":
          description: same_location / no_lines / invalid_quantity / unknown_location / unknown_product.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/stock-transfers/{id}:
    get:
      tags: [Inventory]
      summary: One transfer with lines (V18)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: The transfer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StockTransferDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/stock-transfers/{id}/send:
    post:
      tags: [Inventory]
      summary: Send a DRAFT transfer (V18, Manager-gated)
      description: DRAFT → IN_TRANSIT. Writes TRANSFER_OUT movements + decrements source stock levels atomically; runs the auto-86 sweep at the source.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200": { description: Now IN_TRANSIT. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Not a DRAFT (`error:"transfer_not_draft"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/stock-transfers/{id}/receive:
    post:
      tags: [Inventory]
      summary: Receive an IN_TRANSIT transfer (V18, Manager-gated)
      description: |
        IN_TRANSIT → RECEIVED. `receiveAll: true` (default) receives every line in full; otherwise pass
        per-line received units. Writes TRANSFER_IN movements + increments destination stock by the
        RECEIVED units — short receipts implicitly record shrinkage-in-transit.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StockTransferReceiveRequest" }
      responses:
        "200": { description: Now RECEIVED. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Not IN_TRANSIT (`error:"transfer_not_in_transit"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/stock-transfers/{id}/cancel:
    post:
      tags: [Inventory]
      summary: Cancel a DRAFT transfer (V18, Manager-gated)
      description: Only DRAFTs cancel — once sent, stock has left the source, so receive (possibly short) instead.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200": { description: Cancelled. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Not a DRAFT (`error:"transfer_not_draft"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ─────────────────────────── Store credit (V18) ───────────────────────────
  /api/v1/customers/{id}/store-credit:
    get:
      tags: [Customers]
      summary: Store-credit balance (V18)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Current balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StoreCreditBalance" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Customers]
      summary: Adjust store credit (V18, Manager-gated)
      description: Positive or negative delta with a reason; every change writes an audit movement. Overdrawing returns 409 insufficient_credit.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StoreCreditAdjustRequest" }
      responses:
        "200":
          description: New balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StoreCreditBalance" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Overdraw (`error:"insufficient_credit"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/customers/{id}/store-credit/redeem:
    post:
      tags: [Customers]
      summary: Redeem store credit at the till (V18)
      description: Atomic decrement — the till redeems server-first, then records the STORE_CREDIT payment leg.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StoreCreditRedeemRequest" }
      responses:
        "200":
          description: New balance after redemption.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StoreCreditBalance" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Balance can't cover it (`error:"insufficient_credit"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/customers/{id}/store-credit/movements:
    get:
      tags: [Customers]
      summary: Store-credit audit trail (V18)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Movements, newest first (max 100).
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/StoreCreditMovementDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/suppliers:
    get:
      tags: [Suppliers & Purchasing]
      summary: List suppliers
      responses:
        "200":
          description: Suppliers, ordered by name.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SupplierDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Suppliers & Purchasing]
      summary: Create a supplier
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupplierCreateRequest" }
      responses:
        "201":
          description: Supplier created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/suppliers/{id}:
    post:
      tags: [Suppliers & Purchasing]
      summary: Update a supplier (Manager-gated, BO-F32)
      description: "Partial update incl. the `active` flag; keep-on-absent. Returns the updated supplier."
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupplierUpdateRequest" }
      responses:
        "200":
          description: Updated supplier.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupplierDto" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/purchase-orders:
    get:
      tags: [Suppliers & Purchasing]
      summary: List purchase orders
      parameters:
        - name: status
          in: query
          description: "Filter, e.g. DRAFT | SENT | RECEIVED."
          schema: { type: string }
      responses:
        "200":
          description: Purchase orders, newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PurchaseOrderDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Suppliers & Purchasing]
      summary: Draft a new purchase order
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PoCreateRequest" }
      responses:
        "201":
          description: PO drafted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  poNumber: { type: string }
                  totalCents: { type: integer, format: int64 }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/purchase-orders/{id}:
    get:
      tags: [Suppliers & Purchasing]
      summary: Get full purchase-order detail
      description: Header (with resolved `supplierName`) plus every line (with resolved `productName`) in one call — the back-office PO screen.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Purchase-order detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseOrderDetailDto" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Purchase order not found (`error:"po_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/purchase-orders/{id}/send:
    post:
      tags: [Suppliers & Purchasing]
      summary: Mark a purchase order SENT
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: Marked SENT.
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/purchase-orders/{id}/lines:
    get:
      tags: [Suppliers & Purchasing]
      summary: Get a purchase order's lines
      description: Line-level detail (ordered vs. received quantities, costs) for the back-office receive dialog.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: PO lines, in sort order.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PoLineDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Purchase order not found (`error:"po_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/purchase-orders/{id}/receive:
    post:
      tags: [Suppliers & Purchasing]
      summary: Receive goods against a purchase order
      description: |
        With no request body (or an empty one), creates a stock-in movement for every line's full outstanding
        (not-yet-received) quantity and marks the PO `RECEIVED`. With a `PoReceiveRequest` body, receives only the
        given `{lineId, receivedQty}` pairs — each `lineId` must belong to this PO and `receivedQty` must be in
        `1..outstanding` for that line — and the PO becomes `RECEIVED` only once every line is fully received,
        otherwise `PARTIAL`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PoReceiveRequest" }
      responses:
        "200":
          description: Goods received.
          content:
            application/json:
              schema: { type: object, properties: { linesReceived: { type: integer } } }
        "400":
          description: A requested `lineId` isn't on this PO, or `receivedQty` is out of range (`error:"line_not_in_po"` or `"invalid_received_qty"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Purchase order not found (`error:"po_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Pricing ─────────────────────────────
  /api/v1/pricing/rules:
    get:
      tags: [Pricing]
      summary: List time-based pricing rules for the tenant
      responses:
        "200":
          description: Rules.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PriceRuleDto" } }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Pricing]
      summary: Create a pricing rule (happy hour, lunch special, day-of-week promo)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PriceRuleCreateRequest" }
      responses:
        "201":
          description: Rule created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/pricing/rules/{id}/toggle:
    post:
      tags: [Pricing]
      summary: Activate or deactivate a pricing rule
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: active
          in: query
          schema: { type: boolean, default: true }
      responses:
        "204":
          description: Toggled.
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/pricing/effective/{productId}:
    get:
      tags: [Pricing]
      summary: Get the current effective price for a product
      description: Applies the highest-priority active rule matching now (PRODUCT scope beats CATEGORY beats ALL; ties broken by creation order).
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Effective price.
          content:
            application/json:
              schema:
                type: object
                properties: { productId: { type: string }, priceCents: { type: string } }
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ───────────────────────────── Customer QR ─────────────────────────────
  /api/v1/qr/menu:
    get:
      tags: [Customer QR]
      summary: Get the public menu for a location (self-order)
      description: |
        Public — no JWT required. Rate limit bucket `qr`: 600 requests/min. Hides products currently 86'd at
        the location. Requires the venue's licence to include the **QR Ordering** product (V72) — otherwise
        `403 qr_not_licensed`. The response's `features` block tells the guest UI what this venue supports:
        hosted card checkout (Yoco), SnapScan, and whether "add to my table's bill" exists at all (it needs
        the Restaurant product; standalone QR always pays immediately).
      x-rate-limit: "qr: 600/min"
      security: []
      parameters:
        - name: loc
          in: query
          required: true
          description: Location id (from the table's QR code).
          schema: { type: string }
      responses:
        "200":
          description: Menu.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QrMenuResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: The venue's licence has no QR Ordering product (`error:"qr_not_licensed"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: Location not found (`error:"location_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/qr/orders:
    post:
      tags: [Customer QR]
      summary: Place a self-order from a table QR code
      description: |
        Public — no JWT required. Rate limit bucket `qr`: 600 requests/min. Server re-prices every line from
        the catalogue (client prices are never trusted); an unknown or 86'd product is a `400`. Fires the
        order to the kitchen immediately after creation (same path as the POS "fire course" button).

        `fulfilmentType` (V10) selects eat-in vs takeaway/click-and-collect. `DINE_IN` (default) requires a
        `tableLabel`; `TAKEAWAY` makes the table optional and accepts `scheduledForMillis` (order-for-later)
        plus `pickupName`/`pickupPhone`. Takeaway kitchen tickets are badged so the KDS can show "TAKEAWAY"
        and the scheduled pickup time.

        `paymentMode` (V72) selects settlement. `PAY_NOW` (default): the order settles online — when Yoco is
        configured the response carries `checkoutUrl` (hosted card checkout, tip included) and the payment
        webhook settles the order the moment it captures. `ADD_TO_TAB` (dine-in only, requires the venue's
        licence to include the Restaurant product): the items append to the table's open bill if one exists
        (response `orderId` is that bill, `settlement:"TABLE_BILL"`, `billTotalCents` is the whole bill) or
        become the table's bill otherwise; the till settles it later.
      x-rate-limit: "qr: 600/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QrOrderRequest" }
      responses:
        "201":
          description: Order placed and fired to the kitchen.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QrOrderResponse" }
        "400":
          description: "Invalid order (`error:` one of `empty_order`, `too_many_lines`, `notes_too_long`, `table_label_too_long`, `missing_table`, `invalid_fulfilment_type`, `invalid_payment_mode`, `tab_requires_table`, `pickup_name_too_long`, `pickup_phone_too_long`, `scheduled_in_past`, `scheduled_too_far`, `invalid_quantity`, `line_note_too_long`, `too_many_modifiers`, `unknown_product`, `product_unavailable`, `unknown_modifier`, `modifier_not_allowed`, `single_select_group`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "`qr_not_licensed` (no QR Ordering on the licence) or `tab_not_available` (ADD_TO_TAB without the Restaurant product)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: Location or table not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/qr/orders/{id}:
    get:
      tags: [Customer QR]
      summary: Get order + kitchen + payment status for the thank-you screen
      description: |
        Public — no JWT required. The order id itself doubles as the unguessable access token. Rate limit
        bucket `qr`: 600 requests/min.
      x-rate-limit: "qr: 600/min"
      security: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Order status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QrOrderStatusResponse" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/qr/orders/{id}/payment:
    get:
      tags: [Customer QR]
      summary: Get the payment surface for an unpaid self-order
      description: |
        Public — no JWT required. Rails in preference order (V72): `YOCO` — a hosted card checkout link
        (idempotency-keyed on the order, so refetching returns the SAME checkout); `SNAPSCAN` — the static
        QR + server-rendered PNG; `PAY_AT_COUNTER` — nothing configured or a zero total. A settled order
        answers `PAID`. Idempotent — repeated calls never stack up new PENDING payment rows. Rate limit
        bucket `qr`: 600 requests/min.
      x-rate-limit: "qr: 600/min"
      security: []
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Payment surface (mode `YOCO`, `SNAPSCAN`, `PAY_AT_COUNTER`, or `PAID`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QrPaymentResponse" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Checklists ─────────────────────────────
  /api/v1/checklists:
    get:
      tags: [Checklists]
      summary: List checklist templates (with items)
      parameters:
        - name: locationId
          in: query
          required: false
          description: Filter to templates scoped to this location (templates with a null location apply everywhere).
          schema: { type: string }
      responses:
        "200":
          description: Templates for the tenant.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ChecklistDto" } }
    post:
      tags: [Checklists]
      summary: Create a checklist template
      description: Manager-gated. Creates a template and its items.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChecklistUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdResponse" }
        "400":
          description: "Invalid input (`error:` one of `title_required`, `title_too_long`, `invalid_recurrence`, `too_many_items`, `item_label_required`, `item_label_too_long`, `invalid_location`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/v1/checklists/{id}:
    post:
      tags: [Checklists]
      summary: Edit a checklist template (metadata + replace items)
      description: |
        Manager-gated. Updates title/recurrence/active/location and replaces the item set: items present by
        id are updated, new items (no id) are inserted, and dropped items are removed (which also removes their
        run-item ticks — editing a template reshapes its runs).
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChecklistUpsertRequest" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdResponse" }
        "400":
          description: Invalid input (same codes as create).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/checklists/{id}/runs:
    post:
      tags: [Checklists]
      summary: Open (idempotent) today's run for a checklist
      description: |
        Any authenticated staff. Opens a run for the checklist on a business date (default: today,
        Africa/Johannesburg; override with `?date=yyyy-MM-dd`). Idempotent — re-opening returns the existing
        run (`200`) rather than duplicating it. On first open, snapshots the current template items as
        unticked run items.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: date
          in: query
          required: false
          description: Business date `yyyy-MM-dd` (defaults to today).
          schema: { type: string }
      responses:
        "201":
          description: New run opened.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistRunDto" }
        "200":
          description: Existing run for that date.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistRunDto" }
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/checklists/runs:
    get:
      tags: [Checklists]
      summary: List runs for a business date (with progress)
      parameters:
        - name: date
          in: query
          required: false
          description: Business date `yyyy-MM-dd` (defaults to today).
          schema: { type: string }
        - name: locationId
          in: query
          required: false
          description: Filter to runs whose parent checklist is scoped to this location.
          schema: { type: string }
      responses:
        "200":
          description: Runs for the date.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ChecklistRunDto" } }
  /api/v1/checklists/runs/{runId}/items/{itemId}:
    post:
      tags: [Checklists]
      summary: Mark a run item done/undone
      description: |
        Any authenticated staff. Toggles a run item's `done` flag; when done, records the acting staff
        (`byStaffId` if supplied + valid, else the caller) and a timestamp. Recomputes the run's status
        (`COMPLETE` when every item is done, else `OPEN`).
      parameters:
        - name: runId
          in: path
          required: true
          schema: { type: string }
        - name: itemId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChecklistItemMarkRequest" }
      responses:
        "200":
          description: Updated item + run progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistItemMarkResponse" }
        "400":
          description: "Invalid input (`error:` one of `note_too_long`, `unknown_staff`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "Run or run item not found (`error:` `run_not_found` | `run_item_not_found`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /api/v1/checklists/summary/send:
    post:
      tags: [Checklists]
      summary: Build + email the daily checklist summary
      description: |
        Manager-gated (also suitable for a cron caller holding a manager token, like the birthday-voucher
        scheduler). Aggregates the business date's checklist runs — which checklists are COMPLETE vs OPEN,
        per-item done counts, who completed items, and the still-outstanding items — into a plain-text and
        HTML summary, then emails it via Mailjet to `CHECKLIST_SUMMARY_TO` (from `MAILJET_FROM`).

        Dev-safe: when the Mailjet keys (`MAILJET_API_KEY` / `MAILJET_SECRET_KEY`) or the recipient are blank it
        does NOT fail — it logs the rendered body and returns `200` with `status: email_not_configured` (or
        `no_recipient`) plus the rendered `textBody`/`htmlBody`, so the aggregation is testable without a live
        Mailjet account. A send that
        throws is caught and reported as `send_failed` (also `200`). A successful send returns `202`.
      parameters:
        - name: date
          in: query
          required: false
          description: Business date `yyyy-MM-dd` (defaults to today, Africa/Johannesburg).
          schema: { type: string }
        - name: locationId
          in: query
          required: false
          description: Filter to runs whose parent checklist is scoped to this location.
          schema: { type: string }
      responses:
        "202":
          description: "Summary built and emailed (status `sent`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistSummarySendResponse" }
        "200":
          description: >-
            Summary built; not emailed (status one of `email_not_configured`, `no_recipient`,
            `send_failed`). The rendered body is still returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistSummarySendResponse" }
        "400":
          description: "Invalid `date` (`error:` `invalid_date`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "Caller is not a manager (`error:` `manager_required`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ───────────────────────────── Booking ─────────────────────────────
  /api/v1/booking/availability:
    get:
      tags: [Booking]
      summary: Suggested reservation time slots for a date + party size
      description: |
        Public — no JWT required. Rate limit bucket `booking`: 60 requests/min. Returns 30-min slots across
        the service window (11:00–21:00 Africa/Johannesburg). A slot is `available` when fewer than 6
        reservations start in its 30-min window (pacing) and — when the location has tables configured — the
        overlapping covers plus this party fit the seat capacity. Past slots for today are omitted.
      x-rate-limit: "booking: 60/min"
      security: []
      parameters:
        - name: loc
          in: query
          required: true
          description: Location id.
          schema: { type: string }
        - name: date
          in: query
          required: true
          description: Booking date, `yyyy-MM-dd` (Africa/Johannesburg).
          schema: { type: string, example: "2026-07-20" }
        - name: partySize
          in: query
          required: true
          description: Number of guests (1..20).
          schema: { type: integer }
      responses:
        "200":
          description: Available/unavailable slots for the date.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BookingAvailabilityResponse" }
        "400":
          description: "Invalid input (`error:` one of `missing_loc`, `missing_date`, `invalid_date`, `missing_party_size`, `invalid_party_size`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/booking/reservations:
    post:
      tags: [Booking]
      summary: Create a guest reservation (PENDING)
      description: |
        Public — no JWT required. Rate limit bucket `booking`: 60 requests/min. Creates a `PENDING`
        reservation (no table assigned — staff confirm + seat later). Validates hard: `partySize` 1..20,
        a future `reservedForAt` within 90 days, at least one contact (`phone` or `email`), and re-runs the
        pacing + capacity gate — a full window returns `409 booking_unavailable`.
      x-rate-limit: "booking: 60/min"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingReservationRequest" }
      responses:
        "201":
          description: Reservation created (PENDING).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BookingReservationResponse" }
        "400":
          description: "Invalid input (`error:` one of `guest_name_required`, `guest_name_too_long`, `invalid_party_size`, `invalid_duration`, `contact_required`, `phone_too_long`, `email_too_long`, `notes_too_long`, `reservation_in_past`, `reservation_too_far`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "No capacity at that time (`error: booking_unavailable`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Receipts ─────────────────────────────
  /receipts/{number}:
    get:
      tags: [Receipts]
      summary: View a digital receipt (public, signed-token link)
      description: |
        Public — no JWT required, and **not** under `/api/v1`. The signed token `t` (HMAC, minted server-side
        at checkout) authorizes access to exactly `tenantId|number`; an absent, tampered, expired, or
        mismatched token all 404 so sequential receipt numbers can't be probed for existence. Returns
        rendered HTML, not JSON.
      security: []
      parameters:
        - name: number
          in: path
          required: true
          description: Transaction number (e.g. `POS-000123`).
          schema: { type: string }
        - name: t
          in: query
          required: true
          description: Signed token minted by the server at checkout time.
          schema: { type: string }
      responses:
        "200":
          description: Rendered HTML receipt.
          content:
            text/html:
              schema: { type: string }
        "404":
          description: Receipt not found, or token missing/invalid/expired/mismatched (`error:"receipt_not_found"`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ── Stocktake sessions (V20) ──
  /accounting/connection:
    get:
      tags: [Accounting]
      summary: The accounting connection (Manager-gated)
      description: >
        Which system is connected, how far the push has got, and what failed.
        `credentialsPresent` says whether this server actually holds credentials for the
        provider — the difference between "nobody connected it" and "the token expired".
      responses:
        "200":
          description: 'The connection. `provider: NONE` when nothing is connected.' 
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountingConnection" }
    post:
      tags: [Accounting]
      summary: Connect, pause, or set the earliest period (Manager-gated)
      description: >
        Credentials are NEVER accepted here — they come from the environment, so a
        compromised session cannot exfiltrate or replace the keys to a venue's ledger.
        `provider: "NONE"` or `active: false` pauses the push without forgetting the settings.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountingConnectionRequest" }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountingConnection" }
        "400":
          description: '`unknown_provider` or `invalid_date`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /accounting/sync:
    post:
      tags: [Accounting]
      summary: Push unposted journals now (Manager-gated)
      description: >
        Oldest first. Each entry is claimed before the request goes out (unique on
        tenant+entry) and carries a derived idempotency key, so a retry after a timeout
        re-sends the same document rather than duplicating revenue. `inFlight` counts
        entries whose outcome is unknown and which stay claimed for the next run.
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, minimum: 1, maximum: 500 } }
      responses:
        "200":
          description: What the batch did. `notConfigured` when there is nothing to push to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountingSyncResult" }
  /service-orders/{id}/costing:
    get:
      tags: [Shipping]
      summary: The costing sheet on a workshop job (V70)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The sheet, or exists=false when the job has none yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceCosting" }
        "404":
          description: Unknown job, or another tenant's.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /service-orders/{id}/costing/lines:
    post:
      tags: [Shipping]
      summary: Replace the labour and parts lines
      description: >-
        Labour is hours x rate; parts are catalogue products. The two are never conflated —
        a part billed in hours is refused, as is labour holding a product, because the split is
        the only margin question a service desk runs on.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lines]
              properties:
                lines:
                  type: array
                  items: { $ref: "#/components/schemas/ServiceCostingLine" }
      responses:
        "200":
          description: The updated sheet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceCosting" }
        "400":
          description: '`a_part_is_not_billed_in_hours`, `labour_cannot_hold_a_product`, `labour_needs_hours_and_rate`, `unknown_product`, `already_closed`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: '`quote_exceeded` — the message names both exits (re-approve, or a manager write-off).'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /service-orders/{id}/costing/quote:
    post:
      tags: [Shipping]
      summary: Record the quoted figure
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { cents: { type: integer, format: int64, minimum: 0 } }
      responses:
        "200":
          description: The updated sheet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceCosting" }
  /service-orders/{id}/costing/approve:
    post:
      tags: [Shipping]
      summary: Record what the customer agreed to
      description: >-
        Ordinary counter work, not manager-gated — phoning a customer for a bigger number is not
        a manager task. The job may never bill more than this, less any write-off.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { cents: { type: integer, format: int64, minimum: 0 } }
      responses:
        "200":
          description: The updated sheet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceCosting" }
        "409":
          description: '`approval_below_work_done`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /service-orders/{id}/costing/write-off:
    post:
      tags: [Shipping]
      summary: Absorb an overrun (Manager-gated)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { cents: { type: integer, format: int64, minimum: 0 } }
      responses:
        "200":
          description: The updated sheet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceCosting" }
        "403":
          description: '`manager_required` — money leaving the business is a manager decision.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: '`write_off_exceeds_work`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /room-charge/connection:
    get:
      tags: [Accounting]
      summary: Which PMS, and whether this server can reach it (V71)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Not connected is a first-class answer, not an error.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoomChargeConnection" }
    post:
      tags: [Accounting]
      summary: Connect, pause, or set the property reference (Manager-gated)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RoomChargeConnectionRequest" }
      responses:
        "200":
          description: Saved. Credentials are never accepted here — they are environment-only.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoomChargeConnection" }
        "403":
          description: '`manager_required`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /room-charge/availability:
    get:
      tags: [Accounting]
      summary: May the till offer the tender at all?
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Deliberately not manager-gated — a cashier's till asks this.
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  notConnected: { type: boolean }
  /room-charge/lookup:
    post:
      tags: [Accounting]
      summary: Confirm room and guest before charging
      description: >-
        An empty room is refused and cannot be overridden — charging room 302 because somebody
        said "302" is how a hotel loses money. An unreachable PMS is refused unless the property
        has opted into unverified charges.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [room]
              properties: { room: { type: string } }
      responses:
        "200":
          description: The guest to read back, or a refusal with its reason.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoomChargeLookup" }
  /room-charge/charge:
    post:
      tags: [Accounting]
      summary: Post a sale to the folio
      description: >-
        The posting row is claimed in Aura BEFORE the request leaves, unique on
        (tenant, transaction) — so a double-press, two tills or a manual re-send collapse onto one
        folio line. The key is derived, never generated, so a retry re-sends the identical
        document. An Unknown outcome stays claimed and is retried by the sweep; a 4xx is terminal.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RoomChargeRequest" }
      responses:
        "200":
          description: Outcome, including alreadyPosted for a repeat.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoomChargePosting" }
  /room-charge/reverse:
    post:
      tags: [Accounting]
      summary: Reverse a posting (Manager-gated)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transactionId]
              properties: { transactionId: { type: string } }
      responses:
        "200":
          description: The reversed posting.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoomChargePosting" }
  /room-charge/sweep:
    post:
      tags: [Accounting]
      summary: Retry everything still unresolved
      description: Re-sends each PENDING claim with its original key. Never re-claims.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: How many moved, and to what.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attempted: { type: integer }
                  posted: { type: integer }
                  failed: { type: integer }
                  stillPending: { type: integer }
  /room-charge/postings:
    get:
      tags: [Accounting]
      summary: Per-sale outcome — reception's worklist
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [PENDING, POSTED, FAILED, REVERSED] } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, minimum: 1, maximum: 500 } }
      responses:
        "200":
          description: Newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RoomChargePosting" }
  /feeds/{tenantId}/{storeId}/products.xml:
    get:
      tags: [Shipping]
      summary: Product feed for Google Merchant / Meta Commerce (public, token-gated)
      description: >-
        RSS 2.0 with the `g:` namespace. The token is derived by HMAC from the ids in the path and
        checked in constant time BEFORE any database query, so an unauthenticated caller cannot
        make this endpoint do work. A missing, wrong or other-tenant token is 404 rather than 403 —
        a public URL should not confirm that something exists. Cached for 15 minutes and served
        with an ETag, so a daily crawl usually costs a 304. Never contains cost prices, supplier
        names or stock quantities.
      parameters:
        - { name: tenantId, in: path, required: true, schema: { type: string } }
        - { name: storeId, in: path, required: true, schema: { type: string } }
        - { name: t, in: query, required: true, description: The feed token from /api/v1/feeds/stores., schema: { type: string } }
        - { name: If-None-Match, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: The feed.
          content:
            application/xml:
              schema: { type: string }
        "304":
          description: Unchanged since the ETag the caller presented.
        "404":
          description: '`feed_not_found` — unknown store, or a token that does not match.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /feeds/stores:
    get:
      tags: [Shipping]
      summary: Connected stores and their feed URLs (Manager-gated)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: One row per connected storefront, each with a ready-to-paste URL.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ProductFeedStore" }
        "403":
          description: '`manager_required`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /shipping/methods:
    get:
      tags: [Shipping]
      summary: The delivery price list
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: includeInactive, in: query, required: false, schema: { type: boolean, default: false } }
      responses:
        "200":
          description: Each method with its bands.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ShippingMethod" }
    post:
      tags: [Shipping]
      summary: Create or replace a delivery option (Manager-gated)
      description: >-
        Replaces the method AND all of its bands. A price list is edited as one table, so a
        merge would leave a removed row still pricing parcels.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShippingMethodRequest" }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdResponse" }
        "400":
          description: '`name_required`, `unknown_kind`, `negative_price`, `bands_required`, `invalid_band`, `duplicate_band`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: '`manager_required`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /shipping/methods/{id}:
    delete:
      tags: [Shipping]
      summary: Retire a delivery option (Manager-gated)
      description: Soft — parcels already raised keep the service name they were sent with.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Retired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdResponse" }
        "404":
          description: '`not_found`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /shipping/zones:
    get:
      tags: [Shipping]
      summary: Postcode prefix to zone
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: The zone table.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ShippingZone" }
    post:
      tags: [Shipping]
      summary: Replace the zone table (Manager-gated)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [zones]
              properties:
                zones:
                  type: array
                  items: { $ref: "#/components/schemas/ShippingZone" }
      responses:
        "200":
          description: How many rules were stored.
          content:
            application/json:
              schema:
                type: object
                properties: { saved: { type: integer } }
        "400":
          description: '`blank_zone`, `duplicate_prefix` (one prefix cannot map to two zones).'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /shipping/quote:
    post:
      tags: [Shipping]
      summary: What would delivery cost?
      description: >-
        Send `weightG` for a parcel somebody has weighed, or `lines` to have the server weigh the
        basket from the catalogue. An option with `available: false` carries a `reason`:
        `needs_weight` (something in the basket is unweighed), `no_band` (heavier than every band),
        or `zone_not_served`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShippingQuoteRequest" }
      responses:
        "200":
          description: One option per method, deliverable ones cheapest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShippingQuoteResponse" }
  /shipping/shipments:
    get:
      tags: [Shipping]
      summary: Parcels, newest first
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [PENDING, PACKED, SHIPPED, DELIVERED, CANCELLED] } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, minimum: 1, maximum: 500 } }
      responses:
        "200":
          description: Parcels.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Shipment" }
    post:
      tags: [Shipping]
      summary: Raise a parcel
      description: >-
        Allocates a waybill number, unique for the life of the account. The address, weight and
        price are copied onto the parcel: a customer moving house must not rewrite where last
        month's parcel went, and a later rate rise must not rewrite what this one cost.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentRequest" }
      responses:
        "201":
          description: Raised, status PENDING.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Shipment" }
        "400":
          description: '`recipient_required`, `address_required`, `city_required`, `postcode_required`, `negative_price`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /shipping/shipments/{id}/status:
    post:
      tags: [Shipping]
      summary: Move a parcel along
      description: >-
        `PENDING -> PACKED -> SHIPPED -> DELIVERED`, with `CANCELLED` available from the first
        three. A delivered parcel cannot be un-packed. `CANCELLED` is manager-only, because a
        parcel already on a van means a refund or a recall.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [PENDING, PACKED, SHIPPED, DELIVERED, CANCELLED] }
                trackingRef: { type: string, nullable: true, description: From the courier's slip, or a carrier integration later. }
      responses:
        "200":
          description: The parcel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Shipment" }
        "403":
          description: '`manager_required` (cancelling only).'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: '`illegal_transition` — the message names the move that was refused.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /shipping/shipments/{id}/waybill:
    get:
      tags: [Shipping]
      summary: The A4 sheet for the parcel
      description: >-
        Address at the largest size the page allows, sender block, the waybill number as a Code 128
        barcode and as readable digits, service, weight, notes and a signature block. Generated from
        the parcel, so it can be reprinted at any time.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "404":
          description: '`not_found`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /accounting/postings:
    get:
      tags: [Accounting]
      summary: Per-entry posting outcomes (Manager-gated)
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [PENDING, POSTED, FAILED, SKIPPED] } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, minimum: 1, maximum: 500 } }
      responses:
        "200":
          description: Newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AccountingPosting" }
        "400":
          description: '`unknown_status`.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/counts:
    get:
      tags: [Inventory]
      summary: Stocktake history
      description: Newest first, capped at 100. Progress and variance totals per session.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, schema: { type: string }, description: "Omit for every location." }
        - { in: query, name: status, schema: { type: string, enum: [DRAFT, COUNTING, REVIEW, APPROVED, CANCELLED] } }
      responses:
        "200":
          description: Sessions.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/StockCountSummary" } }
    post:
      tags: [Inventory]
      summary: Open a stocktake session (Manager)
      description: |
        Snapshots every in-scope SKU with what the books say now — that snapshot is
        the variance baseline. One open session per location at a time.

        Only `track_stock` products are included, and a product with variants
        contributes one line per variant (never a parent roll-up), matching
        `GET /inventory/stock`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OpenCountRequest" }
      responses:
        "201":
          description: Session opened.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OpenCountResponse" }
        "400":
          description: "`invalid_scope_type`, `scope_ids_required`, `empty_scope` or `unknown_location`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`count_already_open` — another session at this location is still open."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/counts/{id}:
    get:
      tags: [Inventory]
      summary: One stocktake with its lines
      description: |
        On a **blind** session that is still `COUNTING`, `expectedUnits` and
        `varianceUnits` are omitted from every line. This is enforced server-side,
        not in the UI. They appear once the session reaches `REVIEW`.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Session with lines.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StockCountSession" }
        "404":
          description: "`count_not_found` — including another tenant's session."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/counts/{id}/lines:
    post:
      tags: [Inventory]
      summary: Submit counted quantities
      description: |
        Open to all staff — counting is floor work. Partial submissions are
        expected and re-submitting a line overwrites it, so a mis-keyed figure is
        corrected by sending it again.

        A counted `0` is a real count and posts a variance; a line never submitted
        stays uncounted and posts nothing.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubmitCountRequest" }
      responses:
        "200":
          description: The updated session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StockCountSession" }
        "400":
          description: "`empty_counts`, `invalid_counted_qty` or `line_not_in_session`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`count_not_counting` — the session has moved on."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/counts/{id}/review:
    post:
      tags: [Inventory]
      summary: Send a stocktake for review
      description: "COUNTING → REVIEW. Expected quantities become visible even on a blind count."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: The session, now in REVIEW.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StockCountSession" }
        "400":
          description: "`nothing_counted`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`count_not_counting`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/counts/{id}/approve:
    post:
      tags: [Inventory]
      summary: Approve and post the variance (Manager)
      description: |
        Posts `counted - expected` as a stock **movement**, not as an absolute
        on-hand. This matters: stock that sold between counting and approval is
        preserved rather than silently re-created. Count 10 against a book figure
        of 12, sell 2 while it sits in review, and on-hand lands on 8.

        Uncounted lines post nothing. Approval is terminal — a second call 409s
        rather than double-posting. Runs the auto-86 sweep afterwards.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: What was posted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApproveCountResponse" }
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`count_not_approvable` — already approved or cancelled."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/counts/{id}/cancel:
    post:
      tags: [Inventory]
      summary: Abandon a stocktake (Manager)
      description: "Refused once APPROVED — those movements cannot be taken back."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string }
        "409":
          description: "`count_terminal`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ── Valuation (V21) ──
  /inventory/valuation:
    get:
      tags: [Inventory]
      summary: Stock value at tracked cost (Manager)
      description: |
        `totalValueCents` is what the units on the shelf actually cost, tracked as
        a moving weighted average by the stock ledger. `listValueCents` is the same
        stock priced at `products.cost_cents`. A wide gap between them means the
        list costs are stale — that gap is the finding, not a rounding error.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, required: true, schema: { type: string } }
      responses:
        "200":
          description: Valuation, with a per-category breakdown.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValuationReport" }
        "400":
          description: "`missing_locationId`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/valuation/history:
    get:
      tags: [Inventory]
      summary: Stock value over time (Manager)
      description: |
        One point per day, built by walking today's value *backwards* through the
        movement ledger. Needs no scheduled job and cannot drift from the ledger.

        Limit: movements written before V21 carry no value delta, so a window
        reaching back past the upgrade flattens rather than showing real history.
        `coveredFrom` is the earliest movement in the window.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, required: true, schema: { type: string } }
        - { in: query, name: days, schema: { type: integer, default: 30, minimum: 1, maximum: 365 } }
      responses:
        "200":
          description: Daily closing values.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValuationHistory" }
        "400":
          description: "`missing_locationId` or `invalid_days`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/valuation/cogs:
    get:
      tags: [Inventory]
      summary: Cost of goods sold (Manager)
      description: |
        From the cost each sale was actually costed at, so margin is against what
        the goods cost rather than today's list price.

        Only `SALE` movements count as COGS. Wastage, expiry and supplier returns
        are reported separately — they are losses, not cost of goods *sold*, and
        folding them in would flatter or punish margin depending on the month.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, required: true, schema: { type: string } }
        - { in: query, name: days, schema: { type: integer, default: 30, minimum: 1, maximum: 365 } }
      responses:
        "200":
          description: COGS with losses broken out.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CogsReport" }

  # ── Batches / lots (V23) ──
  /inventory/batches:
    get:
      tags: [Inventory]
      summary: Live lots at a location
      description: "In FEFO pick order. Exhausted lots are retained for traceability but hidden unless `includeEmpty=true`."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, required: true, schema: { type: string } }
        - { in: query, name: productId, schema: { type: string } }
        - { in: query, name: includeEmpty, schema: { type: boolean, default: false } }
      responses:
        "200":
          description: Lots.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/BatchEntry" } }
    post:
      tags: [Inventory]
      summary: Record a lot directly (Manager)
      description: |
        For opening balances and deliveries that never had a purchase order.
        Normally lots are created by receiving a PO.

        Posts through the stock ledger, so the aggregate level and value move with
        it. Refused with `product_not_batch_tracked` unless the product has
        `trackBatches` on — a lot on an untracked product is a row nothing would
        ever deplete.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBatchRequest" }
      responses:
        "201":
          description: Lot recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateBatchResponse" }
        "400":
          description: "`invalid_quantity`, `invalid_unit_cost`, `batch_code_blank`, `unknown_product`, `unknown_variant`, `unknown_location` or `product_not_batch_tracked`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/batches/expiring:
    get:
      tags: [Inventory]
      summary: Expiry worklist
      description: |
        Lots going off within `days`, plus anything **already** past its date —
        those are the most urgent items on the list, so they are included rather
        than filtered out. `valueAtRiskCents` is the money that leaves in the bin
        if nothing is done.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, required: true, schema: { type: string } }
        - { in: query, name: days, schema: { type: integer, default: 30, minimum: 0 } }
      responses:
        "200":
          description: Expired and expiring-soon lots.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExpiringReport" }
  /inventory/batches/trace/{batchCode}:
    get:
      tags: [Inventory]
      summary: Recall trace for a lot code
      description: "Deliberately across every location — a recall is not a per-shop question."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: batchCode, required: true, schema: { type: string } }
      responses:
        "200":
          description: Where the lot is, and how much is left.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchTrace" }
        "404":
          description: "`batch_code_not_found` — including another tenant's lot code."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /inventory/batches/reconcile:
    get:
      tags: [Inventory]
      summary: Lot totals vs aggregate on-hand
      description: |
        Both are written in the same transaction so they should agree. A non-empty
        result means stock moved through a path that bypassed lot tracking — most
        often a product that already held stock when `trackBatches` was switched on.

        Reports drift and does **not** correct it: adjusting one side to match the
        other would destroy the evidence of which path is wrong.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, required: true, schema: { type: string } }
      responses:
        "200":
          description: Drifting SKUs; empty when everything agrees.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/BatchDrift" } }
  /inventory/batches/write-off:
    post:
      tags: [Inventory]
      summary: Bin expired lots (Manager)
      description: |
        Takes explicit lot ids rather than a "write off everything expired" switch —
        throwing stock away is a decision per lot, and a blanket endpoint invites a
        cron job to quietly destroy inventory.

        Recorded as `EXPIRY`, not `WASTAGE`, so spoilage-through-age stays separable
        from breakage and theft.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WriteOffBatchRequest" }
      responses:
        "200":
          description: What was written off.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WriteOffBatchResponse" }
        "400":
          description: "`no_batches` or `unknown_batch` (including another tenant's lot)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ── Supplier returns / RTV (V22) ──
  /supplier-returns:
    get:
      tags: [Suppliers]
      summary: Supplier returns
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: locationId, schema: { type: string } }
        - { in: query, name: supplierId, schema: { type: string } }
        - { in: query, name: status, schema: { type: string, enum: [DRAFT, SENT, CREDITED, CANCELLED] } }
      responses:
        "200":
          description: Returns with their lines.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SupplierReturn" } }
    post:
      tags: [Suppliers]
      summary: Raise a return (Manager)
      description: |
        Creates a DRAFT. Stock does **not** move yet — a draft is a picking list and
        the units are still physically in the store. Lines are priced on send.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateReturnRequest" }
      responses:
        "201":
          description: Draft raised.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  reference: { type: string }
        "400":
          description: "`no_lines`, `invalid_quantity`, `invalid_reason`, `duplicate_line`, `unknown_location`, `unknown_supplier`, `unknown_product` or `unknown_variant`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /supplier-returns/outstanding:
    get:
      tags: [Suppliers]
      summary: Claims awaiting credit
      description: "Sent returns with no credit recorded, oldest first — the order to chase them in."
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Outstanding claims.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SupplierReturn" } }
  /supplier-returns/{id}:
    get:
      tags: [Suppliers]
      summary: One return with its lines
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: The return.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupplierReturn" }
        "404":
          description: "`return_not_found` — including another tenant's return."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /supplier-returns/{id}/send:
    post:
      tags: [Suppliers]
      summary: Send a return (Manager)
      description: |
        DRAFT → SENT. Stock leaves the shelf with reason `RTV` — **not** `WASTAGE` —
        which is what keeps returned goods out of the shrinkage report: the money is
        owed, not lost.

        Each line is costed at the moving average at this moment and that figure is
        frozen onto the line, so a later price change cannot restate a claim the
        supplier has already received. Runs the auto-86 sweep afterwards.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Sent, with the claim total.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendReturnResponse" }
        "409":
          description: "`return_not_draft`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /supplier-returns/{id}/credit:
    post:
      tags: [Suppliers]
      summary: Record a credit note (Manager)
      description: |
        `creditCents` may be **less** than the claim — a short credit is the normal
        case, not an error — and the difference surfaces as `shortfallCents`. A
        credit of `0` records a refused claim, which is a real outcome and better
        recorded than left permanently outstanding.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreditReturnRequest" }
      responses:
        "200":
          description: The credited return.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupplierReturn" }
        "400":
          description: "`invalid_credit`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`return_not_sent`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /supplier-returns/{id}/cancel:
    post:
      tags: [Suppliers]
      summary: Cancel a draft return (Manager)
      description: "DRAFT only — a sent return's goods have physically gone. Record a credit of 0 for a refused claim instead."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string }
        "409":
          description: "`return_not_draft`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ── Loyalty points (V24) ──
  /loyalty/settings:
    get:
      tags: [Loyalty]
      summary: The tenant's loyalty economics
      description: |
        Both sides of the exchange: the earn rate and what a point is worth when
        spent. Every value is an integer — a float in a conversion a guest can
        audit at the till is a defect waiting to be reported as theft.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoyaltySettings" }
    post:
      tags: [Loyalty]
      summary: Change the loyalty rates (Manager)
      description: "Keep-on-absent: omitted fields keep their current value."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoyaltySettingsRequest" }
      responses:
        "200":
          description: The updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoyaltySettings" }
        "400":
          description: "`invalid_earn_rate`, `invalid_point_value` (0 is refused — it would empty a balance silently), `invalid_minimum` or `invalid_max_pct`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /loyalty/balance/{customerId}:
    get:
      tags: [Loyalty]
      summary: Points balance and recent ledger
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: customerId, required: true, schema: { type: string } }
      responses:
        "200":
          description: Balance, its cash value, and the last 20 ledger entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoyaltyBalance" }
        "404":
          description: "`customer_not_found` — including another tenant's customer."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /loyalty/quote:
    post:
      tags: [Loyalty]
      summary: What may be spent on this bill
      description: |
        Commits nothing. Shares its arithmetic with `/redeem`, so the figure quoted
        at the till is the figure that gets charged.

        `maxValueCents` is what the redeemable points are worth *here*;
        `balanceValueCents` is what the whole balance is worth. They differ when a
        cap bites, which `cappedByBill` reports — a till needs both to say "worth
        R340, R210 usable on this bill".

        The cap chain is: the guest's balance, then `maxRedeemPctBp` of the bill,
        then what is actually still owed (subtotal less discounts already applied).
        Capping against the subtotal instead would let a voucher and a redemption
        together exceed the bill and drive the total negative.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoyaltyQuoteRequest" }
      responses:
        "200":
          description: The quote. Check `available`; `reason` says why not.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoyaltyQuote" }
        "404":
          description: "`customer_not_found` or `order_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /loyalty/redeem:
    post:
      tags: [Loyalty]
      summary: Spend points on an open order
      description: |
        Open to all staff: redeeming a guest's own points is a normal till action,
        not a discretionary discount, and the amount is bounded by the tenant's
        settings rather than the operator's judgement.

        The discount lands in `pos_transactions.discount_cents` exactly like a
        voucher, so totals, VAT and every downstream report need no special case.
        A `LOYALTY` row is written to `discounts` alongside it, and the order
        records the points spent so a reversal knows what to return.

        Asking for more than the quote allows is **refused**, not silently trimmed:
        a till that quietly redeems fewer points than the cashier keyed gets
        reported as losing the guest's points. One live redemption per order — the
        order row is locked for the check, so two tills cannot both redeem.

        Earning on the net bill falls out for free: checkout computes points from
        the discounted total, so a guest does not earn fresh points on the portion
        their old points just paid for.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoyaltyRedeemRequest" }
      responses:
        "201":
          description: Redeemed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoyaltyRedeemResult" }
        "400":
          description: "`invalid_points`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "`customer_not_found` or `order_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`order_not_open`, `already_redeemed`, `exceeds_redeemable`, `redemption_disabled`, `no_points`, `nothing_to_discount` or `below_minimum`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /loyalty/redeem/reverse:
    post:
      tags: [Loyalty]
      summary: Hand the points back
      description: |
        For a redemption keyed against the wrong guest, or an order about to be
        abandoned. Returns the points, removes the discount, and writes a
        `REDEEM_REVERSED` ledger row rather than deleting the original — the
        reversal is a new fact, not an amendment.

        Only while the order is still OPEN. Once paid, the discount is part of a
        settled bill and a refund is the correct instrument; clawing points back
        from a closed sale would leave the takings and the ledger disagreeing.

        After a reversal the same order may redeem again — a cashier who keys 250
        instead of 25 is doing something legitimate.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoyaltyReverseRequest" }
      responses:
        "200":
          description: Reversed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoyaltyReverseResult" }
        "409":
          description: "`nothing_redeemed`, `order_not_open` or `no_customer_on_order`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ── Promotions (V25) ──
  /promotions:
    get:
      tags: [Promotions]
      summary: List promotions
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: type, schema: { type: string, enum: [BUY_X_GET_Y, MULTI_BUY, BASKET_PERCENT, BASKET_FIXED] } }
      responses:
        "200":
          description: Promotions, highest priority first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Promotion" } }
    post:
      tags: [Promotions]
      summary: Create a promotion (Manager)
      description: |
        Distinct from `/pricing` rules, which modify a **unit price** inside a time
        window (happy hour). Promotions reason about the **basket**: "buy 2 get 1"
        counts units across lines and decides which one is free, "3 for R100" prices
        a group, and "10% off over R500" depends on a total that does not exist until
        every line is in. The two compose — happy hour sets the prices, promotions
        run over the resulting basket.

        Validation refuses offers that could never work: a `MULTI_BUY` without a
        bundle price, a `BUY_X_GET_Y` giving away 0%, a scoped promotion pointing at
        a category that does not exist.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PromotionUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: "`name_required`, `promo_type_required`, `invalid_promo_type`, `invalid_scope_type`, `scope_ids_required`, `bundle_price_required`, `invalid_buy_quantity`, `invalid_get_discount`, `invalid_value`, `invalid_days_of_week`, `unknown_product` or `unknown_category`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`code_in_use` — codes are unique per tenant, because ambiguity would resolve by row order."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /promotions/{id}:
    post:
      tags: [Promotions]
      summary: Update a promotion (Manager)
      description: "Keep-on-absent: omitted fields keep their current value."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PromotionUpsertRequest" }
      responses:
        "200":
          description: The updated promotion.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Promotion" }
        "404":
          description: "`promotion_not_found` — including another tenant's."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /promotions/{id}/toggle:
    post:
      tags: [Promotions]
      summary: Activate or deactivate (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: The promotion, with `active` flipped.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Promotion" }
        "404":
          description: "`promotion_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /promotions/evaluate:
    post:
      tags: [Promotions]
      summary: What applies to an order
      description: |
        Commits nothing — a till calls this on every cart change so the guest can see
        the offer land. Shares its code path with `/apply` and with checkout, so a
        guest cannot be quoted one figure and charged another.

        `skipped` explains every live promotion that did **not** apply, which is what
        lets a cashier answer "why didn't my offer come off?".
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EvaluateRequest" }
      responses:
        "200":
          description: Awards and skips.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PromotionEvaluation" }
        "404":
          description: "`order_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /promotions/apply:
    post:
      tags: [Promotions]
      summary: Evaluate and write the discount to the order
      description: |
        Idempotent by recomputation: the previous promotion discount is removed and
        the freshly derived one added, so calling it repeatedly as a cart changes
        cannot accumulate. A manager's goodwill discount or a redeemed points balance
        sharing the same `discount_cents` column is left untouched.

        Calling this is **optional** — checkout re-evaluates promotions itself, so an
        automatic offer applies whether or not the client remembers. Use it to show
        the discount on the order before payment.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EvaluateRequest" }
      responses:
        "200":
          description: What was written.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApplyPromotionsResponse" }
        "409":
          description: "`order_not_open`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /promotions/{id}/performance:
    get:
      tags: [Promotions]
      summary: What a promotion has given away (Manager)
      description: |
        `usageCount` is the counter the engine gates on; `salesCount` and the totals
        come from the redemption rows. Both are reported — if they disagree the
        counter has drifted and the rows are the truth.

        The counter increments once per **sale**, not per application, and only at
        checkout. Counting in `/apply` would exhaust a limited-use offer on a
        customer who never paid.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Performance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PromotionPerformance" }

  # ── Composite bundles (V26) ──
  /bundles:
    get:
      tags: [Catalogue]
      summary: Every bundle with its slots and options
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Bundles.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Bundle" } }
    post:
      tags: [Catalogue]
      summary: Create or replace a bundle (Manager)
      description: |
        Replace-not-patch, like recipes: send the whole bundle. `201` on create, `200`
        on replace. Patching slots individually invites a half-edited meal deal that
        prices wrongly at the till.

        Distinct from a recipe, which explodes a dish into raw **ingredients**. A
        bundle's components are full products with their own stock, VAT type and
        kitchen station, so ringing one up expands into real sale lines.

        Refused when the bundle product tracks stock (`bundle_cannot_track_stock`) —
        its components deplete, and tracking both would double-count. Also refused
        for a bundle containing itself, or another bundle: expanding a nested bundle
        would need a selection the till has no way to prompt for.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BundleUpsertRequest" }
      responses:
        "200":
          description: Replaced.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bundle" }
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bundle" }
        "400":
          description: "`empty_bundle`, `empty_slot`, `invalid_pricing_mode`, `invalid_slot_quantity`, `slot_quantity_exceeds_options`, `duplicate_option`, `self_referencing_component`, `nested_bundle`, `unknown_product`, `unknown_component`, `unknown_variant` or `bundle_cannot_track_stock`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "403":
          description: "`manager_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /bundles/{productId}:
    get:
      tags: [Catalogue]
      summary: One bundle
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: productId, required: true, schema: { type: string } }
      responses:
        "200":
          description: The bundle.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bundle" }
        "404":
          description: "`bundle_not_found` — including another tenant's."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: [Catalogue]
      summary: Stop a product being a bundle (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: productId, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted. Slots and options cascade.
        "404":
          description: "`bundle_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /bundles/{productId}/quote:
    post:
      tags: [Catalogue]
      summary: Price a selection
      description: |
        Commits nothing — a till calls this as the guest chooses, so the price on
        screen is the price that gets rung up. Omit `selections` to price the
        bundle's defaults.

        Component prices are read **live**, so a menu price change flows into every
        bundle automatically rather than needing them re-saved.

        Each component's `lineTotalCents` is its apportioned share of the bundle
        price, pro-rata by list price with the truncation remainder given to the
        dearest component. The shares always total the bundle price exactly.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: productId, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BundleQuoteRequest" }
      responses:
        "200":
          description: The priced components.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BundleQuote" }
        "400":
          description: "`slot_required`, `wrong_selection_count`, `option_not_in_slot`, `unknown_slot` or `nothing_selected`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`bundle_inactive`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /orders/{orderId}/bundle:
    post:
      tags: [Orders]
      summary: Ring a bundle onto an order
      description: |
        Expands into **one sale line per component**, each priced at its apportioned
        share and all sharing a `bundleGroupId` so a bill can group them back into
        "Burger Meal — R120".

        Expanding rather than holding one opaque line means stock depletion, kitchen
        routing, VAT apportionment, per-product revenue and the 86 sweep all keep
        working untouched. Each component line carries the **component's** product id
        and its own `vatType`, so a bundle mixing standard-rated and zero-rated goods
        is taxed correctly with no special case.

        `quantity` creates that many independent bundle instances, each with its own
        group id — two meals on a bill are two things a guest can send back
        separately.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: orderId, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AddBundleRequest" }
      responses:
        "201":
          description: Lines added.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AddBundleResponse" }
        "400":
          description: "`invalid_quantity`, or any selection error from the quote endpoint."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "`order_not_found` or `bundle_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`order_not_open` or `bundle_inactive`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ── Price lists (V27) ──
  # ─────────────────── Click & collect (V34) ───────────────────
  /public/collect/{tenantId}/{locationId}/slots:
    get:
      tags: ["Click & Collect"]
      summary: Bookable collection windows (public)
      description: >-
        No JWT — a customer ordering has no account. Slots are derived from
        per-location settings rather than stored, so changing the interval applies
        immediately. Full slots are omitted rather than greyed out: a public surface
        should not advertise a time it will then refuse. 404 until the venue enables
        collection.
      security: []
      parameters:
        - { name: tenantId, in: path, required: true, schema: { type: string } }
        - { name: locationId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Windows with remaining capacity.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectSlots" }
        "404":
          description: "`location_not_found` or `collect_not_enabled`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /public/collect/{tenantId}/{locationId}/catalogue:
    get:
      tags: ["Click & Collect"]
      summary: What can be ordered right now (public)
      description: >-
        Quantified by `on_hand - reserved`, never on-hand: showing on-hand would
        promise the last item to two people, and the second finds out on arrival. A
        tracked product with nothing available is omitted entirely — this is an
        ordering surface, and a listing you cannot buy is a dead end. A non-tracked
        product returns a null unit count, having nothing to run out of.
      security: []
      parameters:
        - { name: tenantId, in: path, required: true, schema: { type: string } }
        - { name: locationId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Orderable products, priced at this location.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CollectCatalogueItem" } }
        "404":
          description: "`collect_not_enabled`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /public/collect/{tenantId}/{locationId}/orders:
    post:
      tags: ["Click & Collect"]
      summary: Place a collection order (public)
      description: |
        Holds the stock. `reserved_units` is incremented under `SELECT … FOR UPDATE`
        with availability re-read inside the lock, so two customers ordering the last
        item at the same moment cannot both succeed — the second gets
        `409 out_of_stock`.

        **All-or-nothing**: a mixed order with one short line reserves nothing. A
        customer arriving to an incomplete order they have paid for is worse than
        being refused now.

        Prices are resolved server-side at this location and frozen — the price shown
        is the price paid, and a client cannot set its own.

        `clientToken` is strongly recommended: it makes a double-tap idempotent,
        returning 200 with `duplicate: true` and creating nothing.

        The requested slot is validated against the generated list, so an 03:00 slot,
        a non-boundary time, or one inside the lead time cannot be booked by posting a
        timestamp.
      security: []
      parameters:
        - { name: tenantId, in: path, required: true, schema: { type: string } }
        - { name: locationId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlaceCollectOrderRequest" }
      responses:
        "201":
          description: Placed and holding stock.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectPlaced" }
        "200":
          description: This clientToken had already ordered — nothing was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectPlaced" }
        "400":
          description: >-
            `contact_name_required`, `contact_phone_required`, `no_lines`,
            `invalid_quantity`, `unknown_product`, `unknown_variant` or
            `pay_on_collection_not_allowed`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`slot_unavailable` or `out_of_stock` (which names the products)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /public/collect/{tenantId}/orders/{id}:
    get:
      tags: ["Click & Collect"]
      summary: Track an order (public)
      description: >-
        Deliberately narrow: progress and a sentence, and none of the customer's phone
        number, the note, the reservation flag, the expiry or the line detail. A public
        surface shows progress, not the venue's operational state. The id is the
        capability, as a signed receipt link is.
      security: []
      parameters:
        - { name: tenantId, in: path, required: true, schema: { type: string } }
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectTracking" }
        "404":
          description: "`order_not_found` — including another tenant's."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/settings:
    get:
      tags: ["Click & Collect"]
      summary: Per-location configuration
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: locationId, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Settings, or the disabled default.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectSettings" }
    post:
      tags: ["Click & Collect"]
      summary: Configure collection (Manager)
      description: >-
        `enabled` is false until a venue opts in, so nothing about an existing location
        changes on deploy. `allowPayOnCollection` is off by default: an order paid
        online cannot be abandoned, which is the whole risk this feature carries.
        `holdMinutes` is clamped to 1..1440 — zero would expire before the customer
        reached the payment screen.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CollectSettingsRequest" }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema: { type: object, additionalProperties: { type: string } }
        "400":
          description: >-
            `invalid_slot_minutes`, `invalid_capacity`, `invalid_hold`, `invalid_hours`
            or `unknown_location`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/orders:
    get:
      tags: ["Click & Collect"]
      summary: The picking list, in slot order
      description: >-
        Open to any authenticated staff member — waiters do the picking. Each order
        carries `standing`, a sentence naming what to do next rather than a status code.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING_PAYMENT, RESERVED, READY, SHORT, COLLECTED, CANCELLED, EXPIRED]
        - { name: locationId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Orders.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CollectOrder" } }
  /collect/orders/{id}/pay:
    post:
      tags: ["Click & Collect"]
      summary: Take payment
      description: >-
        Creates the sale if needed and captures against it, the same call a layby
        installment and a special-order deposit make, so the money counts toward the
        shift that took it. Full payment **clears the expiry**: a paid order is never
        dropped. Overpaying is refused.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CollectPayRequest" }
      responses:
        "200":
          description: Money position.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectMoney" }
        "400":
          description: "`invalid_amount` or `overpays_order`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`order_closed`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/orders/{id}/ready:
    post:
      tags: ["Click & Collect"]
      summary: Picked and on the collection shelf
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectOrder" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/orders/{id}/notify:
    post:
      tags: ["Click & Collect"]
      summary: Record that the customer was told
      description: Separate from `ready` because the two genuinely come apart.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectOrder" }
        "409":
          description: "`nothing_to_notify`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/orders/{id}/collect:
    post:
      tags: ["Click & Collect"]
      summary: Hand over and settle
      description: >-
        **Releases** the reservation and lets the ordinary checkout deplete. That is
        what makes a collected order cost at the moving average, deplete FEFO lots and
        post to the journal exactly as a walk-in does — reserving and depleting
        separately would need a second costing implementation that would drift. Legal
        from READY or SHORT; a customer may accept a short order.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CollectHandoverRequest" }
      responses:
        "200":
          description: Handed over.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectHandover" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/orders/{id}/cancel:
    post:
      tags: ["Click & Collect"]
      summary: Cancel and release the hold
      description: >-
        Reports `refundDueCents` rather than silently keeping money already taken.
        Idempotent against a racing sweep — the release is guarded by a flag checked
        and cleared in the same locked transaction.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string } } }
      responses:
        "200":
          description: Cancelled, with what was released and what is owed back.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectCancelled" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /collect/sweep:
    post:
      tags: ["Click & Collect"]
      summary: Release expired holds (Manager)
      description: |
        **Idempotent**, so it is safe as both a button and a cron target — and it must
        run. A hold nobody releases is stock nobody can sell, invisible behind an
        on-hand figure that still looks right; over months a shop's availability drains
        away with no error anywhere.

        A **paid** order is never expired — the venue is holding the customer's money.
        Those are marked `SHORT` instead when a walk-in has taken the stock, so
        somebody rings the customer.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: What the sweep did.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectSweepResult" }
  # ─────────────────── Rota & delivery (V32) ───────────────────
  /rota:
    get:
      tags: ["Rota & Delivery"]
      summary: The roster for a window
      description: >-
        Staff see published shifts only — a half-finished week visible to everyone
        produces a morning of questions about shifts never meant to exist — and never
        see cost figures. `forecastCostCents` is null for non-managers.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: locationId, in: query, schema: { type: string } }
        - { name: preset, in: query, schema: { type: string, default: THIS_WEEK } }
      responses:
        "200":
          description: The roster.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rota" }
    post:
      tags: ["Rota & Delivery"]
      summary: Roster a shift (Manager)
      description: |
        A planned shift, deliberately **not** a row in `shifts`: that table records
        attendance, and writing a roster into it would mean publishing next week's
        rota created next week's clock-ins — counted in labour cost and holding
        registers nobody opened.

        Overlaps are refused, including the same person at two different branches.
        Half-open `[startsAt, endsAt)`, so a 17:00 handover is not an overlap and a
        split shift stays rosterable.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RotaUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          description: "`times_required`, `invalid_window`, `invalid_break`, `shift_too_long`, `unknown_staff` or `unknown_location`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`shift_overlaps` — one person cannot be in two places."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /rota/{id}:
    post:
      tags: ["Rota & Delivery"]
      summary: Move or edit a shift (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RotaUpsertRequest" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "404":
          description: "`rota_shift_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: ["Rota & Delivery"]
      summary: Remove a shift (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Deleted. }
        "404":
          description: "`rota_shift_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /rota/publish:
    post:
      tags: ["Rota & Delivery"]
      summary: Make a window visible to staff (Manager)
      description: >-
        Idempotent, and does **not** re-stamp already-published shifts — so "when was
        this published" keeps answering what staff actually ask: when could I first
        have seen it.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RotaPublishRequest" }
      responses:
        "200":
          description: How many shifts became visible.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RotaPublishResult" }
  /rota/mine:
    get:
      tags: ["Rota & Delivery"]
      summary: My own published shifts
      description: Keyed on the authenticated user, so a staff member can only fetch their own roster.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: preset, in: query, schema: { type: string, default: THIS_WEEK } }
      responses:
        "200":
          description: Shifts.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/RotaShift" } }
  /rota/variance:
    get:
      tags: ["Rota & Delivery"]
      summary: Rostered against worked (Manager)
      description: |
        The number neither table gives alone. Matched by staff member over the window
        rather than shift-by-shift: a pairing would need a rule for someone who worked
        a shift they were not rostered for, and every such rule guesses. Someone in
        only one list still gets a line — a shift worked with nobody rostered, and a
        roster nobody turned up for, are both what this exists to surface.

        Forecast cost uses **today's** rate; actual cost uses the rate frozen on each
        shift (V31). That asymmetry is intended: a plan prices at current pay, a past
        report must not move.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: locationId, in: query, schema: { type: string } }
        - { name: preset, in: query, schema: { type: string, default: LAST_WEEK } }
      responses:
        "200":
          description: Variance per staff member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RotaVariance" }
  /delivery/platforms:
    get:
      tags: ["Rota & Delivery"]
      summary: Aggregators configured (Manager)
      description: The webhook secret is never echoed back — only whether one is set.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Platforms.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DeliveryPlatform" } }
    post:
      tags: ["Rota & Delivery"]
      summary: Configure an aggregator (Manager)
      description: >-
        One row per platform per location, so it is never ambiguous which commission
        rate and secret apply. `commissionBp` is capped at 10000 — above 100% the
        payout would be negative, which is a percent/basis-point slip rather than a
        commission arrangement.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeliveryPlatformUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          description: "`platform_required`, `invalid_platform`, `invalid_commission` or `location_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`platform_already_configured`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /delivery/platforms/{id}:
    post:
      tags: ["Rota & Delivery"]
      summary: Update an aggregator (Manager)
      description: >-
        A rate change applies to orders from here on. Orders already ingested keep the
        split they were recorded with, so a renegotiated rate cannot restate last
        month's payouts.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeliveryPlatformUpsertRequest" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "404":
          description: "`platform_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: ["Rota & Delivery"]
      summary: Remove an aggregator (Manager)
      description: Refused once orders have come through — they carry the money split and would be orphaned.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Deleted. }
        "409":
          description: "`platform_has_orders`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /delivery/platforms/{id}/items:
    get:
      tags: ["Rota & Delivery"]
      summary: The platform item map (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Mappings.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DeliveryItemMapping" } }
    post:
      tags: ["Rota & Delivery"]
      summary: Map a platform item to a product (Manager)
      description: >-
        Clears the unmapped flag on any order whose items are now all mapped, but does
        **not** rewrite those orders' lines — their stock movements and journal are
        already posted. The mapping applies from here on.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeliveryItemMapRequest" }
      responses:
        "200":
          description: How many orders left the queue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  externalItemId: { type: string }
                  ordersCleared: { type: integer }
        "400":
          description: "`external_item_id_required`, `unknown_product` or `unknown_variant`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /delivery/orders:
    get:
      tags: ["Rota & Delivery"]
      summary: Delivery orders, newest first (Manager)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Orders.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DeliveryOrder" } }
    post:
      tags: ["Rota & Delivery"]
      summary: Ingest an aggregator order
      description: |
        **Idempotent on (platform, external id).** Every one of these platforms
        retries; without the unique index a retry creates a second sale, a second
        kitchen ticket and a second stock depletion, and the duplicate is only noticed
        weeks later when the platform statement fails to reconcile. A repeat returns
        200 with `duplicate: true` and creates nothing.

        Prices come from the platform's payload, not the catalogue: an aggregator's
        menu price is usually marked up, and re-pricing would make the sale disagree
        with both the customer's receipt and the statement.

        The sale settles through the ordinary checkout path with a `DELIVERY` tender —
        the customer has already paid the platform — so stock depletes, VAT computes
        and the journal posts as for any sale. Revenue is the **gross**; the
        platform's cut is recorded as commission.

        **An unmapped item does not reject the order.** Refusing would lose the trade
        and hide the order from the kitchen. The line is anchored to a per-tenant
        system product and the order flagged into `/delivery/orders/unmapped`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeliveryIngestRequest" }
      responses:
        "201":
          description: Ingested and settled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeliveryIngestResult" }
        "200":
          description: Already seen — nothing was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeliveryIngestResult" }
        "400":
          description: "`external_id_required`, `no_lines`, `invalid_quantity` or `invalid_price`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "`unknown_platform`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`platform_inactive`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /delivery/orders/unmapped:
    get:
      tags: ["Rota & Delivery"]
      summary: The mapping queue (Manager)
      description: Orders with an item nobody has mapped, listing the ids to map.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Orders needing attention.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DeliveryOrder" } }
  /delivery/orders/{id}/reconcile:
    post:
      tags: ["Rota & Delivery"]
      summary: Record what the platform actually paid (Manager)
      description: >-
        One-cent tolerance absorbs the platform rounding the other way. Reports the
        **implied commission rate**, which is how a venue discovers the rate it is
        really being charged rather than the one it was quoted — a consistent gap is
        usually a rate change nobody mentioned.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [actualPayoutCents]
              properties:
                actualPayoutCents: { type: integer, format: int64 }
      responses:
        "200":
          description: The comparison.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeliveryReconciliation" }
        "404":
          description: "`delivery_order_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  # ─────────────────── Menus & invoices (V33) ───────────────────
  /menus/effective:
    get:
      tags: ["Menus & Invoices"]
      summary: What this till should show
      description: |
        Open to any authenticated staff member — a till needs it on every sync.

        A location with **no menu**, or one whose menu has **never been published**,
        gets every active product, which is exactly what every location did before
        this feature existed. Nothing changes on deploy, and an accidental assignment
        cannot blank a till. `source` says which mode it is in.

        Products deactivated globally are filtered even when a menu still lists them.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: locationId, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: The effective product list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EffectiveMenu" }
        "404":
          description: "`location_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /menus:
    get:
      tags: ["Menus & Invoices"]
      summary: Menus with their versions (Manager)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Menus.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Menu" } }
    post:
      tags: ["Menus & Invoices"]
      summary: Create a menu (Manager)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string } } }
        "400":
          description: "`name_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`name_in_use`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /menus/{id}/items:
    post:
      tags: ["Menus & Invoices"]
      summary: Set the draft's products (Manager)
      description: >-
        Writes to the **draft** version only, so the live menu is untouched until
        publish — a till syncing mid-edit gets the last coherent version rather than a
        menu with the starters removed and the mains not yet added.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                productIds: { type: array, items: { type: string } }
      responses:
        "200":
          description: The draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MenuDraft" }
        "400":
          description: "`unknown_product`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "`menu_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /menus/{id}/publish:
    post:
      tags: ["Menus & Invoices"]
      summary: Publish the draft (Manager)
      description: >-
        A single integer bump, so every till moves from one coherent version to the
        next with no intermediate state. The published items are carried forward as
        the next draft, so an edit starts from what is live. An **empty** draft is
        refused — publishing nothing would leave a till blank, which is far more
        likely a mistake than an intention.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Published.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MenuPublishResult" }
        "400":
          description: "`empty_menu`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /menus/{id}/rollback:
    post:
      tags: ["Menus & Invoices"]
      summary: Republish the previous version (Manager)
      description: Possible at all because versions are additive — a rollback is an integer write, not a restore.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Rolled back.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MenuPublishResult" }
        "409":
          description: "`nothing_to_roll_back_to` or `previous_version_empty`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /menus/{id}/assign:
    post:
      tags: ["Menus & Invoices"]
      summary: Point locations at a menu (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                locationIds: { type: array, items: { type: string } }
      responses:
        "200":
          description: How many were assigned.
          content:
            application/json:
              schema: { type: object, additionalProperties: { type: string } }
        "400":
          description: "`unknown_location`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /menus/{id}:
    delete:
      tags: ["Menus & Invoices"]
      summary: Delete a menu (Manager)
      description: >-
        Unassigns its locations first: one left pointing at a deleted menu would fall
        back to every active product silently, which looks like the feature failing
        rather than the menu being gone.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Deleted. }
        "404":
          description: "`menu_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices:
    get:
      tags: ["Menus & Invoices"]
      summary: Invoices, with OVERDUE derived (Manager)
      description: >-
        `status=OVERDUE` means ISSUED and past its due date — derived, never stored, so
        it cannot drift between sweeper runs. The filter runs after the derivation.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [DRAFT, ISSUED, OVERDUE, PAID, VOID] }
        - { name: houseAccountId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Invoices.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Invoice" } }
    post:
      tags: ["Menus & Invoices"]
      summary: Raise a draft invoice (Manager)
      description: |
        `transactionIds` consolidates sales onto it, which is the month-end case for a
        house account.

        **A sale can only ever be invoiced once** —
        `UNIQUE (tenant_id, transaction_id)` on invoice lines. Without it,
        consolidating twice (a double-click, a re-run, two people doing month-end)
        bills the customer for the same meals twice, and the error is only found when
        they refuse to pay. A sale already billed returns `409 already_invoiced`.

        Billing details are snapshotted from the account, so a later change of contact
        does not alter a document already sent.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceCreateRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  invoiceNumber: { type: string, example: "INV-20260804-000001" }
        "400":
          description: "`bill_to_required`, `unknown_house_account`, `unknown_customer`, `unknown_location` or `unknown_transaction`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`already_invoiced`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices/{id}:
    get:
      tags: ["Menus & Invoices"]
      summary: One invoice with its lines and payments (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "404":
          description: "`invoice_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices/{id}/lines:
    post:
      tags: ["Menus & Invoices"]
      summary: Replace the lines while DRAFT (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceLinesRequest" }
      responses:
        "200":
          description: Recomputed totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvoiceTotals" }
        "409":
          description: "`invoice_not_editable` — an issued invoice is corrected by credit note."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices/{id}/issue:
    post:
      tags: ["Menus & Invoices"]
      summary: DRAFT → ISSUED (Manager)
      description: >-
        After this the invoice is immutable: a tax invoice is a legal document, and two
        versions with one number can never reconcile. `termsDays` sets the due date; 0
        means due on issue, null means no due date, which never goes overdue.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                termsDays: { type: integer, nullable: true, default: 30 }
      responses:
        "200":
          description: The issued invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "400":
          description: "`invoice_empty`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices/{id}/payment:
    post:
      tags: ["Menus & Invoices"]
      summary: Record money received (Manager)
      description: >-
        Partial payment is normal on account. Overpaying is refused rather than
        absorbed: money beyond an invoice is a credit on the account, and swallowing it
        here would lose it.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoicePaymentRequest" }
      responses:
        "200":
          description: The invoice, marked PAID once settled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "400":
          description: "`invalid_amount` or `overpays_invoice`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`invoice_not_payable`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices/{id}/void:
    post:
      tags: ["Menus & Invoices"]
      summary: Void an invoice (Manager)
      description: >-
        DRAFT or ISSUED only — a PAID invoice cannot be voided because money has moved
        and voiding would leave it unexplained. Voiding releases its sales so they can
        be invoiced properly, while the invoice keeps its number: a number is never
        reused, which is what an audit expects.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string } } }
      responses:
        "200":
          description: The voided invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /invoices/{id}/credit-note:
    post:
      tags: ["Menus & Invoices"]
      summary: Correct an issued invoice (Manager)
      description: >-
        The only way. A new invoice with a negative total and negated lines, issued
        immediately with no draft stage, pointing at the original. Both documents
        survive, which is what the VAT Act expects. The original keeps its sales, so
        they still cannot be re-billed. Once credited, a second attempt is refused, and
        a credit note cannot itself be credited.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string } } }
      responses:
        "201":
          description: The credit note.
          content:
            application/json:
              schema: { type: object, additionalProperties: { type: string } }
        "409":
          description: "`invoice_not_issued`, `already_credited` or `already_a_credit_note`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  # ─────────────────── Saved reports & labour (V31) ───────────────────
  /reports/presets:
    get:
      tags: ["Saved Reports"]
      summary: Date presets, resolved against now
      description: |
        Returns each preset with its current window, so a UI can show "Last month
        (1–31 July)" rather than making the user guess.

        Windows are half-open `[from, until)` and resolved in **SAST** — half-open so
        adjacent periods tile exactly (last month's `until` *is* this month's `from`),
        and SAST because a venue closing at 02:00 needs "yesterday" to mean their
        trading day. In-progress periods end at **now**, not at a future boundary.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Presets.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReportPreset" } }
  /reports/templates:
    get:
      tags: ["Saved Reports"]
      summary: Saved reports, each with its window resolved (Manager)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: >-
            Templates. `resolvedFrom`/`resolvedUntil`/`periodLabel` are computed live
            and never stored.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReportTemplate" } }
    post:
      tags: ["Saved Reports"]
      summary: Save a report (Manager)
      description: |
        **Stores a preset, not two dates.** Saving whatever range was on screen means
        "Last month", saved in August, reports July forever — it keeps working, keeps
        looking right, and is wrong from September onwards, with nothing complaining.
        A genuinely frozen range uses `CUSTOM` plus `customFrom`/`customUntil`, which
        is explicit about being frozen rather than accidentally so.

        `reportType` is whitelisted **here**, not at delivery: a template naming a
        report that does not exist would otherwise fail unattended at 07:00.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportTemplateUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: >-
            `name_required`, `report_type_required`, `invalid_report_type`,
            `invalid_preset`, `custom_range_required`, `invalid_window` or
            `unknown_location`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`name_in_use`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/templates/{id}:
    post:
      tags: ["Saved Reports"]
      summary: Update a saved report (Manager)
      description: Keep-on-absent — an omitted field keeps its current value.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportTemplateUpsertRequest" }
      responses:
        "200":
          description: The updated template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportTemplate" }
        "404":
          description: "`template_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: ["Saved Reports"]
      summary: Delete a saved report (Manager)
      description: >-
        Its schedules cascade, but the delivery history is kept — it is the record of
        what was emailed to whom, and retiring a schedule does not un-send last
        month's report.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted.
        "404":
          description: "`template_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/templates/{id}/run:
    get:
      tags: ["Saved Reports"]
      summary: Run a saved report (Manager)
      description: |
        Resolves the template's preset against **now**, so the same template run in
        September reports August — not whatever month it was created in.

        Returns columns and rows of strings rather than a typed shape per report, so
        one CSV writer serves every report; a per-report serialiser silently loses a
        column whenever someone forgets one.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The rendered report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RenderedReport" }
        "404":
          description: "`template_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/schedules:
    get:
      tags: ["Saved Reports"]
      summary: Scheduled deliveries (Manager)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Schedules, with the outcome of their last delivery.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReportSchedule" } }
    post:
      tags: ["Saved Reports"]
      summary: Schedule a saved report (Manager)
      description: |
        The cadence decides **when and how often**; the template's own preset decides
        **what period the report covers**. Deriving the content window from the
        cadence would make a template that says "Last month" email yesterday's
        figures — the saved intent silently overridden.

        `dayOfMonth` is capped at **28**: a monthly report set for the 30th would not
        fire in February, which reads as the feature being broken rather than as a
        configuration choice. A schedule with no recipients is refused, because it
        would run, succeed, and deliver nothing.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportScheduleUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: >-
            `invalid_cadence`, `recipients_required`, `invalid_recipient`,
            `invalid_hour`, `invalid_day_of_week`, `invalid_day_of_month` or
            `invalid_format`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404":
          description: "`template_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/schedules/{id}/toggle:
    post:
      tags: ["Saved Reports"]
      summary: Pause or resume a schedule (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The new state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  active: { type: string }
        "404":
          description: "`schedule_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/schedules/{id}:
    delete:
      tags: ["Saved Reports"]
      summary: Delete a schedule (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted.
        "404":
          description: "`schedule_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/schedules/run-due:
    post:
      tags: ["Saved Reports"]
      summary: Deliver everything due (Manager)
      description: |
        **Idempotent.** `UNIQUE (schedule_id, period_key)` makes "once per period" a
        property of the schema rather than of whoever wrote the scheduler, so seven
        calls deliver once — which is what makes this safe as both a button and a cron
        target.

        Due-ness is "has the hour passed and have we not covered this period", not "is
        it exactly 07:00": an exact match misses the period entirely whenever the
        process is restarting or a minute late, and the report simply never arrives.

        A schedule already delivered for its period returns `ALREADY_SENT` rather than
        being omitted — "it ran and there was nothing to do" and "it did not run" are
        different answers to the only question asked when a report has not arrived.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: One outcome per schedule considered.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReportDeliveryOutcome" } }
  /reports/deliveries:
    get:
      tags: ["Saved Reports"]
      summary: What went out, and what did not (Manager)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Recent deliveries, newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReportDelivery" } }
  /reports/labour:
    get:
      tags: ["Saved Reports"]
      summary: Labour cost against sales (Manager)
      description: |
        The rate is the one **snapshotted onto each shift at clock-in**, so a pay rise
        cannot restate a past report — "labour was 28% of sales in May" stays 28%.

        An overnight shift is **clipped** to the window rather than counted whole in
        both days it spans, and its break is pro-rated by the clipped portion; without
        that, a Friday-night shift would double the weekend's labour cost. **Open
        shifts count up to now**, because a live figure excluding everyone currently
        working is useless mid-service. **Zero sales with labour on the clock reports
        0%, not infinity** — a closed day someone was paid for, shown as a huge
        number, would make every genuine outlier invisible beside it.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: preset, in: query, schema: { type: string }, description: A preset wins over explicit dates. }
        - { name: from, in: query, schema: { type: integer, format: int64 } }
        - { name: until, in: query, schema: { type: integer, format: int64 } }
        - { name: locationId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Labour cost, the percentage, and a per-staff breakdown.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LabourReport" }
  /reports/by-dimension:
    get:
      tags: ["Saved Reports"]
      summary: Sales grouped by brand, supplier or category (Manager)
      description: |
        `brand` and `supplier_id` have sat on products since V18 and nothing ever
        aggregated by them, so "which supplier's stock earns us the most" — the
        question a buyer asks before renegotiating — had no answer.

        Products with nothing recorded go into an explicit `__unset__` bucket labelled
        "Not recorded", **never dropped**: dropping them would make this report's
        total quietly disagree with the sales report, and that bucket is exactly the
        list of products someone needs to go and fill in. It is drillable.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: dimension
          in: query
          schema: { type: string, enum: [brand, supplier, category], default: brand }
        - { name: preset, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: integer, format: int64 } }
        - { name: until, in: query, schema: { type: integer, format: int64 } }
        - { name: locationId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Grouped revenue, cost and margin, biggest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DimensionReport" }
        "400":
          description: "`invalid_dimension`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /reports/drilldown:
    get:
      tags: ["Saved Reports"]
      summary: The sales behind one report row (Manager)
      description: >-
        A total nobody can open is a total nobody trusts: "Suppliers R48,000" is only
        useful if you can see which sales it came from. Pass `value=__unset__` to open
        the "Not recorded" bucket, which is how you find products missing a supplier.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: dimension
          in: query
          schema: { type: string, enum: [brand, supplier, category] }
        - { name: value, in: query, required: true, schema: { type: string } }
        - { name: preset, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: integer, format: int64 } }
        - { name: until, in: query, schema: { type: integer, format: int64 } }
        - { name: locationId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Up to 500 sale lines, newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DrilldownLine" } }
        "400":
          description: "`invalid_dimension` or `value_required`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  # ─────────────────────── Commission (V30) ───────────────────────
  /commission/schemes:
    get:
      tags: ["Commission"]
      summary: Commission schemes (Manager)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Schemes, with how many entries each has earned.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CommissionScheme" } }
    post:
      tags: ["Commission"]
      summary: Create a scheme (Manager)
      description: |
        Schemes **stack**: "2% on everything" plus "5% on wine" is a deliberate
        arrangement, and a line can earn under both.

        `rateBp` is basis points (500 = 5%) and is capped at 10000 — a higher value
        is almost always a percent/basis-point mix-up, and catching it here is
        cheaper than catching it on a payslip.

        An unrecognised `basis` or `scopeType` pays **nothing** rather than
        everything: a configuration typo must not silently widen a scheme to the
        whole menu.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CommissionSchemeUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: >-
            `name_required`, `rate_required`, `invalid_rate`, `invalid_basis`,
            `invalid_scope`, `scope_ids_required`, `unknown_staff` or `invalid_window`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`name_in_use`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /commission/schemes/{id}:
    post:
      tags: ["Commission"]
      summary: Update a scheme (Manager)
      description: |
        Keep-on-absent. Editing a rate does **not** restate commission already
        accrued — entries carry their own frozen `rateBp`, so the change applies to
        future sales only. A mid-month rate rise must not silently re-price the
        first half of the month.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CommissionSchemeUpsertRequest" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "404":
          description: "`scheme_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: ["Commission"]
      summary: Delete a scheme (Manager)
      description: >-
        Refused once anything has been earned under it: entries reference the scheme
        and are pay records, so deleting it would make a payslip unexplainable.
        Deactivate instead.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted.
        "409":
          description: "`scheme_has_entries`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /commission:
    get:
      tags: ["Commission"]
      summary: Per-staff commission for a period (Manager)
      description: |
        `period` is `YYYY-MM` in SAST, defaulting to the current month. Amounts are
        signed, so reversals net off. `owedCents` is never negative — a staff member
        overpaid because of a later refund is a conversation, not a deduction the
        next payslip makes silently.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: period, in: query, schema: { type: string, example: "2026-08" } }
      responses:
        "200":
          description: Totals per staff member, most owed first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/StaffCommission" } }
  /commission/{staffId}:
    get:
      tags: ["Commission"]
      summary: The entries behind one staff member's number (Manager)
      description: >-
        What makes commission arguable-with rather than argued-about. Each entry's
        `baseCents × rateBp ÷ 10000` reproduces its `amountCents`, so a report can
        never disagree with a payslip. Negative amounts are refund reversals.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: staffId, in: path, required: true, schema: { type: string } }
        - { name: period, in: query, schema: { type: string } }
      responses:
        "200":
          description: Entries, oldest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CommissionEntry" } }
        "404":
          description: "`staff_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /commission/{staffId}/pay:
    post:
      tags: ["Commission"]
      summary: Mark a period's commission paid (Manager)
      description: |
        Only ACCRUED rows move, so paying twice hands over nothing the second time.
        Reversals carry ACCRUED too and settle in the same pass — they are negative
        amounts equally pending settlement, and leaving them behind would make the
        paid figure gross while the total stayed net.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: staffId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PayCommissionRequest" }
      responses:
        "200":
          description: What was handed over.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayCommissionResult" }
        "404":
          description: "`staff_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  # ─────────────────────── Marketing (V29) ───────────────────────
  /segments:
    get:
      tags: ["Marketing"]
      summary: Segments, with a plain-English rule and a live size
      description: |
        `size` is who matches **right now** — membership is derived, not stored.
        `reachableWhatsapp` / `reachableEmail` are reported separately because a
        segment of 200 of whom 40 have opted in is a 40-person campaign, and a
        manager who only sees 200 will blame the message.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Segments.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Segment" } }
    post:
      tags: ["Marketing"]
      summary: Create a segment (Manager)
      description: |
        Criteria are **ANDed** — every non-null field must match, and an empty rule
        is every customer. There is no OR: "gold OR big spenders" is two segments,
        which is also two separately measurable campaigns.

        Impossible windows are refused by name rather than silently returning
        nobody.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SegmentUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: >-
            `name_required`, `impossible_spend_window`, `impossible_visit_window`,
            `invalid_birthday_month`, `invalid_lapsed_days` or `invalid_active_days`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`name_in_use` — a duplicate is invisible to whoever later picks one from a list."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /segments/preview:
    post:
      tags: ["Marketing"]
      summary: Size an unsaved rule
      description: |
        The point of the feature: learn that "gold, lapsed 90 days" is four people
        **before** writing the message, not after sending it. Returns the
        description, the size, how many are reachable per channel, and a sample.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SegmentPreviewRequest" }
      responses:
        "200":
          description: The audience this rule would select.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SegmentPreview" }
  /segments/{id}:
    post:
      tags: ["Marketing"]
      summary: Update a segment (Manager)
      description: |
        Keep-on-absent. Send `ruleProvided: false` to rename without touching the
        criteria — without that flag an update carrying only a name would reset the
        rule to "everyone", the default value being indistinguishable from an
        omitted one.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SegmentUpsertRequest" }
      responses:
        "200":
          description: The updated segment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Segment" }
        "404":
          description: "`segment_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: ["Marketing"]
      summary: Delete a segment (Manager)
      description: |
        Refused if a campaign targeted it — deleting the segment out from under a
        campaign would leave that campaign's audience unexplainable. Deactivate
        instead.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted.
        "409":
          description: "`segment_in_use`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /segments/{id}/members:
    get:
      tags: ["Marketing"]
      summary: Who is in a segment right now
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SegmentMember" } }
  /campaigns:
    get:
      tags: ["Marketing"]
      summary: Campaigns with their outcome counters
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, schema: { type: string } }
      responses:
        "200":
          description: Campaigns.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Campaign" } }
    post:
      tags: ["Marketing"]
      summary: Create a campaign (Manager)
      description: |
        One channel per campaign, because the body is written for the channel — an
        email subject is meaningless on WhatsApp. Reaching people both ways is two
        campaigns, which is also two measurements.

        **Unknown placeholders are rejected here**, not at send: discovering
        `{nmae}` after a thousand messages have gone out cannot be undone. A
        *missing value* never leaves a hole either — `{name}` for a customer with
        no name renders "there", so nobody receives "Hi ,".
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: >-
            `name_required`, `channel_required`, `invalid_channel`, `body_required`
            or `unknown_placeholder` (whose message lists the valid tokens).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /campaigns/{id}:
    get:
      tags: ["Marketing"]
      summary: One campaign
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The campaign.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "404":
          description: "`campaign_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    post:
      tags: ["Marketing"]
      summary: Update a campaign (Manager)
      description: |
        Allowed while DRAFT or SCHEDULED only. A sent campaign is a record of what
        went out; editing its body would make the recipient rows describe a message
        that was never sent.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignUpsertRequest" }
      responses:
        "200":
          description: The updated campaign.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "409":
          description: "`campaign_not_editable`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /campaigns/{id}/send:
    post:
      tags: ["Marketing"]
      summary: Resolve the audience and send (Manager)
      description: |
        **Every audience member gets a recipient row.** A customer who cannot be
        messaged is written as SUPPRESSED with a reason — `NO_CONSENT` (marketing),
        `NO_ADDRESS` (data), `DUPLICATE` (the guard working) or `NOT_CONFIGURED`
        (deployment) — so `sentCount + failedCount + suppressedCount` always
        accounts for `audienceCount`. Collapsing those into one "not delivered"
        number tells a manager which of them to fix: none.

        Rows are written **before** each send, so a process that dies mid-campaign
        leaves an accurate record of how far it got, and
        `UNIQUE (campaign_id, customer_id)` means a retry cannot double-message
        anyone. Addresses are snapshotted, so a later number change does not rewrite
        this campaign's history.

        Sending twice is refused under a row lock on the campaign.

        `dryRun: true` means the channel has no credentials, so **nothing was
        delivered** — reported explicitly rather than counted as success.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendCampaignRequest" }
      responses:
        "200":
          description: What happened.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendCampaignResult" }
        "409":
          description: "`campaign_not_sendable` — already sent, or cancelled."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /campaigns/{id}/cancel:
    post:
      tags: ["Marketing"]
      summary: Cancel before sending (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The cancelled campaign.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "409":
          description: "`campaign_not_cancellable`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /campaigns/{id}/recipients:
    get:
      tags: ["Marketing"]
      summary: Who a campaign reached, and who it did not
      description: >-
        Filterable by status. `suppressReason` names whose job the fix is;
        `channelAddress` is the address as it was at send time.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - name: status
          in: query
          schema: { type: string, enum: [PENDING, SENT, FAILED, SUPPRESSED] }
      responses:
        "200":
          description: Recipients.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/CampaignRecipient" } }
  /lifecycle:
    get:
      tags: ["Marketing"]
      summary: Lifecycle automations, with totals
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Automations.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/LifecycleAutomation" } }
    post:
      tags: ["Marketing"]
      summary: Create or replace an automation (Manager)
      description: |
        **Replaces** any existing automation for the same trigger and channel rather
        than adding a second: two active birthday WhatsApps would send every
        customer two messages, and the dedupe log cannot catch that because they are
        different automations.

        `WIN_BACK` with `days: 0` is refused — every customer would be "lapsed" the
        instant they paid, so the message saying "we miss you" would arrive before
        they left the building.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LifecycleUpsertRequest" }
      responses:
        "201":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: >-
            `invalid_trigger`, `invalid_channel`, `body_required`,
            `unknown_placeholder`, `invalid_days` or `win_back_needs_days`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /lifecycle/run:
    post:
      tags: ["Marketing"]
      summary: Evaluate every automation now (Manager)
      description: |
        **Idempotent by design**, which is what makes it safe both as a button and
        as a schedule. Each trigger's condition stays true for as long as it holds —
        a birthday is true all day, and someone lapsed 60 days is still lapsed
        tomorrow — so a sweep with no memory would send a win-back every single day.
        Each send records a `period_key` unique per (automation, customer): the year
        for a birthday, the `last_visit_at` they lapsed from for a win-back, a
        constant for a welcome, the transaction for a review request. The insert
        **is** the claim, because a check-then-send would let two overlapping sweeps
        both pass the check.

        `skippedAlreadySent` is the dedupe log doing its job, not an error.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: One result per active automation.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/LifecycleRunResult" } }
  /lifecycle/{id}/toggle:
    post:
      tags: ["Marketing"]
      summary: Pause or switch on an automation (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The new state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  active: { type: string }
        "404":
          description: "`automation_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /lifecycle/{id}:
    delete:
      tags: ["Marketing"]
      summary: Delete an automation (Manager)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted.
        "404":
          description: "`automation_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /customers/{id}/consent:
    get:
      tags: ["Marketing"]
      summary: The consent audit trail
      description: >-
        Newest first. Re-consenting appends rather than overwrites, so opting back
        in does not erase an earlier withdrawal — which is the point.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Consent events.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ConsentEvent" } }
    post:
      tags: ["Marketing"]
      summary: Record an opt-in or opt-out
      description: |
        Writes the event and updates the flag the send path reads, in one
        transaction. The flag alone cannot answer "prove they agreed", which is what
        POPIA asks for. Consent is per channel: opting out of WhatsApp says nothing
        about email.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConsentRequest" }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: { type: string }
        "400":
          description: "`invalid_channel` or `invalid_source`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /customers/{id}/contact-history:
    get:
      tags: ["Marketing"]
      summary: What we have already sent this customer
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Campaign contacts, newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ContactHistoryEntry" } }
  # ─────────────────── Quotes & special orders (V28) ───────────────────
  /quotes:
    get:
      tags: ["Quotes & Special Orders"]
      summary: Quotes, newest first
      description: |
        `status` accepts `EXPIRED`, which is **not** a stored status — it is derived by
        comparing `validUntil` to now. Asking for it means "SENT and past its date", so
        such a quote also still appears under `?status=SENT`.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: locationId, in: query, schema: { type: string } }
        - { name: customerId, in: query, schema: { type: string } }
        - name: status
          in: query
          schema:
            type: string
            enum: [DRAFT, SENT, EXPIRED, ACCEPTED, DECLINED, CONVERTED, CANCELLED]
      responses:
        "200":
          description: Quotes with their lines.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Quote" } }
    post:
      tags: ["Quotes & Special Orders"]
      summary: Raise a quote
      description: |
        Creates a DRAFT with its lines. A line's `unitPriceCents` may be omitted, in
        which case it is resolved once through the ordinary layers at the quote's
        location (base → price list → active rules) and then **frozen** — honouring
        the offer later is the whole point of quoting.

        `productId` may be null, making the line free text ("Staff — 3 waiters, 5
        hours"). `description` is always required: the customer reads it, and products
        get renamed. A `variantId` without a `productId` is rejected.

        The quote reserves no stock, so five quotes for the last case of wine all
        succeed.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteCreateRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  reference: { type: string, example: "QT-20260804-000001" }
        "400":
          description: >-
            `no_lines`, `invalid_quantity`, `missing_description`, `invalid_discount`,
            `invalid_vat_type`, `variant_without_product`, `unknown_product`,
            `unknown_variant`, `unknown_location` or `unknown_customer`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/open:
    get:
      tags: ["Quotes & Special Orders"]
      summary: Sent and unanswered, soonest to expire first
      description: |
        The follow-up list. Quotes with no expiry sort last — an open-ended standing
        price for a corporate account needs no chasing, so it should not sit above a
        quote that dies on Friday.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Quotes awaiting an answer.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Quote" } }
  /quotes/{id}:
    get:
      tags: ["Quotes & Special Orders"]
      summary: One quote with its lines
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "404":
          description: "`quote_not_found` — including another tenant's quote."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/{id}/lines:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Replace every line and re-total
      description: |
        Wholesale replacement rather than per-line edits: a quote is negotiated as a
        document, and diffing an edited table into inserts/updates/deletes would add a
        way for the stored total to disagree with the lines that produced it.

        Allowed while DRAFT **or SENT** — revising a price and re-sending is the normal
        shape of a negotiation. Once ACCEPTED the figures are the agreement and this
        returns 409; a change then needs a fresh quote, which is the paper trail an
        argument later depends on.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteLinesRequest" }
      responses:
        "200":
          description: Recomputed totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QuoteTotals" }
        "409":
          description: "`quote_not_editable` — the quote is ACCEPTED, CONVERTED or CANCELLED."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/{id}/send:
    post:
      tags: ["Quotes & Special Orders"]
      summary: DRAFT → SENT
      description: |
        Refuses a quote whose `validUntil` has already passed (`valid_until_past`): the
        customer would receive an offer dead on arrival, and its derived status would
        read EXPIRED the instant it was sent.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The sent quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "400":
          description: "`quote_empty` or `valid_until_past`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`quote_not_draft`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/{id}/accept:
    post:
      tags: ["Quotes & Special Orders"]
      summary: SENT → ACCEPTED
      description: |
        An expired quote is refused rather than silently honoured — re-dating the offer
        is a decision someone should take deliberately.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The accepted quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "409":
          description: "`quote_not_sent` or `quote_expired`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/{id}/decline:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Record a lost quote, with its reason
      description: |
        Allowed from DRAFT as well as SENT: a customer can say no to a verbal number
        before the document ever goes out, and losing that loses the win/loss rate the
        whole record exists to produce.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, example: "Chose a cheaper venue" }
      responses:
        "200":
          description: The declined quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "409":
          description: "`quote_not_open`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/{id}/cancel:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Withdraw a quote
      description: |
        Refused once CONVERTED: a sale sits behind it, and unpicking that is a void or
        refund on the order, not an edit here.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The withdrawn quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "409":
          description: "`quote_converted` or `already_cancelled`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /quotes/{id}/convert:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Turn a quote into an OPEN order
      description: |
        Creates a sale at the quote's location, ready to settle by the ordinary till
        path (stock, VAT, loyalty, journal, receipt).

        **Prices are copied, never re-derived** — `PriceResolver` is not consulted, and
        the quote's header discount rides onto the order so checkout apportions it and
        corrects output VAT exactly as the quote's own totals did. The number on the
        paper is the number charged, to the cent.

        **Once only.** `converted_order_id` is set inside a `SELECT … FOR UPDATE` on the
        quote, so two clicks on a slow connection cannot produce two orders.

        SENT converts as well as ACCEPTED: a customer who walks in with a printout and
        pays has accepted it in the only way that matters. `acceptedAt` is backfilled so
        win/loss does not read the sale as an unanswered enquiry.

        Free-text lines are anchored to a per-tenant system product
        (`product_type = COMMITMENT_LINE`, inactive, untracked) that never appears on a
        menu; the line's `name_snapshot` carries the agreed wording.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteConvertRequest" }
      responses:
        "201":
          description: The order raised.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QuoteConverted" }
        "400":
          description: "`quote_empty`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: >-
            `already_converted` (carries the existing order id), `quote_expired` or
            `quote_not_convertible`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders:
    get:
      tags: ["Quotes & Special Orders"]
      summary: Special orders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: locationId, in: query, schema: { type: string } }
        - { name: customerId, in: query, schema: { type: string } }
        - name: status
          in: query
          schema:
            type: string
            enum: [REQUESTED, ORDERED, READY, COLLECTED, CANCELLED]
      responses:
        "200":
          description: Orders with their money position.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SpecialOrder" } }
    post:
      tags: ["Quotes & Special Orders"]
      summary: Request goods for a named customer
      description: |
        `customerId` is required — an order held for nobody in particular is just stock,
        and knowing who to ring is the feature. `productId` may be null: the commonest
        special order is for something not in the catalogue at all, which is usually why
        it is special.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SpecialOrderCreateRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  reference: { type: string, example: "SO-20260804-000001" }
        "400":
          description: >-
            `missing_description`, `invalid_quantity`, `variant_without_product`,
            `unknown_location`, `unknown_customer`, `unknown_product`, `unknown_variant`
            or `unknown_supplier`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/waiting:
    get:
      tags: ["Quotes & Special Orders"]
      summary: Uncollected goods, longest wait first
      description: |
        Everything READY, with `daysWaiting`. This is how floor space and tied-up money
        get recovered: goods bought for one person who never came back are invisible in
        ordinary stock reporting, because on the shelf they look like stock that simply
        is not selling.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Orders on the collection shelf.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SpecialOrder" } }
  /special-orders/{id}:
    get:
      tags: ["Quotes & Special Orders"]
      summary: One special order
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrder" }
        "404":
          description: "`special_order_not_found` — including another tenant's."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/price:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Price it, raising the sale that carries the deposit
      description: |
        The sale is created here rather than at request time because an order cannot be
        raised for a nameless, unpriced thing — and that is how most special orders
        start. Omit `unitPriceCents` for a catalogue item and it is priced at the
        order's location.

        **Re-pricing is allowed until money changes hands** — the customer has not
        committed and the supplier's cost may come back different. Once `paid_cents > 0`
        it returns `409 deposit_taken`: changing the price under a paid deposit would
        silently move the balance the customer agreed to. A re-price reuses the existing
        sale and rewrites its single line, so no duplicate line can appear.

        `depositPctBp` only *suggests* a figure back (rounded up to the cent, so the
        venue never loses a cent per function); nothing is collected here.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SpecialOrderPriceRequest" }
      responses:
        "200":
          description: Priced, with the sale id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrderPriced" }
        "400":
          description: "`price_required`, `invalid_quantity` or `variant_without_product`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`deposit_taken` or `special_order_closed`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/deposit:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Take a deposit
      description: |
        Captures immediately against the order's sale, through the same function a layby
        installment uses — a deposit and a layby installment are the same money
        movement. So the tender counts toward the shift that took it, the day's Z-report
        balances, and V18 checkout credits it at collection.

        It is deliberately **not** a `deposit_cents` column: that would invent a second
        money system where cash sits in the drawer with no register aware of it and no
        accounting entry until collection, so a deposit taken in March would land in
        May's books.

        Overpaying the balance is rejected.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SpecialOrderDepositRequest" }
      responses:
        "200":
          description: Money position after the deposit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrderMoney" }
        "400":
          description: "`invalid_amount` or `overpays_balance`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`not_priced` — there is no sale to attach the money to yet."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/ordered:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Placed with a supplier
      description: Links the purchase order raised, so receiving stock can find the customers waiting on it.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SpecialOrderOrderedRequest" }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrder" }
        "400":
          description: "`unknown_purchase_order` or `unknown_supplier`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/ready:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Arrived and set aside
      description: |
        Deliberately moves **no stock**: the receipt that brought the goods in already
        did, and moving them again would double-count. What this marks is that they are
        spoken for.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrder" }
        "409":
          description: "`invalid_transition`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/notify:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Record that the customer was told
      description: |
        Separate from `/ready` because the two genuinely come apart — goods can sit on
        the shelf for a day before anyone gets hold of the customer. There is no
        NOTIFIED status: whether the customer has been told is a fact about a message,
        not a stage of the goods, and modelling it as a status would make "arrived but
        unreachable by phone" — the state that actually needs chasing — unrepresentable.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrder" }
        "409":
          description: "`nothing_to_notify`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/collect:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Collect and settle the balance
      description: |
        Closes the sale through the ordinary checkout path, so stock depletes, VAT is
        recomputed, loyalty earns, the journal posts and a receipt exists — none of
        which a bespoke "mark collected" would do.

        The deposit needs no mention: it is already CAPTURED on the sale, so the legs
        passed here only have to cover **what is left**. The sale still totals the full
        price.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SpecialOrderCollectRequest" }
      responses:
        "200":
          description: Collected.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrderCollected" }
        "409":
          description: "`invalid_transition` or `not_priced`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /special-orders/{id}/cancel:
    post:
      tags: ["Quotes & Special Orders"]
      summary: Cancel before arrival
      description: |
        Reachable from REQUESTED and ORDERED only. **Not from READY**: the goods are in
        the building, bought for one named person, so the resolution is either that they
        collect or that the venue refunds the deposit and takes the item into stock.
        Both are real operations; silently cancelling would leave physical stock nobody
        owns and a deposit nobody has returned.

        Any deposit already taken is reported back as `depositToRefundCents` rather than
        dropped. The sale is voided so it can never be settled later, but the captured
        payment rows **stay** — the money did change hands, and deleting the evidence
        would leave the register short with no explanation.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "200":
          description: Cancelled, with any deposit still owed back.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpecialOrderCancelled" }
        "409":
          description: "`invalid_transition` — notably READY, where the goods are already in."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /price-lists:
    get:
      tags: [Pricing]
      summary: Price lists with their assigned locations
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Lists.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PriceList" } }
    post:
      tags: [Pricing]
      summary: Create a price list (Manager)
      description: |
        A named set of prices assigned to locations, so a coffee can cost more at the
        airport than in Observatory.

        `upliftBp` moves everything the list does not itemise (1500 = +15%, and it is
        signed, so a staff or wholesale list is the same mechanism). Explicit prices
        for the exceptions go in via `/items`. Supporting only items would mean
        hundreds of rows to express one decision; supporting only an uplift could not
        express a flat-price exception.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PriceListUpsertRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        "400":
          description: "`name_required`, `invalid_uplift` or `invalid_window`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "409":
          description: "`name_in_use` — a duplicate name is invisible to a manager assigning one."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /price-lists/preview:
    get:
      tags: [Pricing]
      summary: What a product costs at a location, and why (Manager)
      description: |
        Returns each layer separately — `basePriceCents` → `listPriceCents` →
        `priceCents` — plus which list and which rule produced them. A price that
        cannot be explained is a support call.

        Omit `locationId` to see base prices with only location-agnostic rules, which
        is what a menu preview should show.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: query, name: productId, required: true, schema: { type: string } }
        - { in: query, name: locationId, schema: { type: string } }
        - { in: query, name: variantId, schema: { type: string } }
      responses:
        "200":
          description: The resolved price, layer by layer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PricePreview" }
        "400":
          description: "`missing_productId`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /price-lists/unassign:
    post:
      tags: [Pricing]
      summary: Send locations back to base prices (Manager)
      description: |
        Not nested under a list id: a manager unassigning a location does not
        necessarily know which list it is on, and making them look it up first invites
        unassigning from the wrong one.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssignRequest" }
      responses:
        "200":
          description: How many locations reverted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  locationsReverted: { type: integer }
  /price-lists/{id}:
    post:
      tags: [Pricing]
      summary: Update a price list (Manager)
      description: "Keep-on-absent: omitted fields keep their current value."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PriceListUpsertRequest" }
      responses:
        "200":
          description: The updated list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceList" }
        "404":
          description: "`price_list_not_found` — including another tenant's."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    delete:
      tags: [Pricing]
      summary: Delete a price list (Manager)
      description: |
        Assigned locations are unassigned first and revert to base prices — the only
        safe reading of "the list is gone", and it avoids leaving a location pointing
        at a row that no longer exists.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "204":
          description: Deleted. Items cascade; locations revert to base.
        "404":
          description: "`price_list_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /price-lists/{id}/items:
    get:
      tags: [Pricing]
      summary: The explicit prices on a list
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Items, with each product's base price for comparison.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PriceListItem" } }
        "404":
          description: "`price_list_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
    post:
      tags: [Pricing]
      summary: Set explicit prices (Manager)
      description: |
        Upsert per SKU, not replace-all: a price list is edited a few rows at a time
        from a long product list, and a replace-all would silently drop everything a
        partial UI did not send.

        A `variantId` of null prices the product for every variant — the variant delta
        still applies on top, so "tees are R150 here" with Large at +R20 rings R170. A
        variant-specific item prices that SKU absolutely and wins outright.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PriceListItemsRequest" }
      responses:
        "200":
          description: The list's items after the upsert.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PriceListItem" } }
        "400":
          description: "`empty_items`, `invalid_price`, `duplicate_item`, `unknown_product` or `unknown_variant`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /price-lists/{id}/items/{itemId}:
    delete:
      tags: [Pricing]
      summary: Drop a SKU back to the list's uplift (Manager)
      description: "Removing an item does not remove the price — the SKU falls back to `upliftBp`."
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { in: path, name: itemId, required: true, schema: { type: string } }
      responses:
        "204":
          description: Removed.
        "404":
          description: "`price_list_not_found` or `item_not_found`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /price-lists/{id}/assign:
    post:
      tags: [Pricing]
      summary: Point locations at this list (Manager)
      description: |
        A location has at most one list — the assignment is a single column on the
        location, so reassigning moves it off whatever list it was on with no special
        case, and "two lists claim this location" is a state the schema cannot express.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssignRequest" }
      responses:
        "200":
          description: The list, with its locations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceList" }
        "400":
          description: "`no_locations` or `unknown_location`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT access token issued by `POST /api/v1/auth/login` or `POST /api/v1/auth/refresh`. Send as
        `Authorization: Bearer <accessToken>`. Manager-gated endpoints additionally require the token's
        `role` claim to be `ADMIN`, `SUPER_ADMIN`, or `MANAGER`.
  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema: { type: string }
    Period:
      name: period
      in: query
      description: "Named period window, resolved in Africa/Johannesburg time. Defaults to `today`."
      schema: { type: string, enum: [today, yesterday, week, month, year], default: today }
    CsvFormat:
      name: format
      in: query
      required: false
      description: "`csv` downloads the report as a CSV attachment; omit for JSON."
      schema: { type: string, enum: [csv] }
    LocationId:
      name: locationId
      in: query
      required: false
      description: "Narrow the report to a single location. Omit for tenant-wide (BO-F18)."
      schema: { type: string }
    RangeFrom:
      name: from
      in: query
      required: false
      description: "Epoch-millis window start. With `until` it overrides `period` (BO-F18)."
      schema: { type: integer, format: int64 }
    RangeUntil:
      name: until
      in: query
      required: false
      description: "Epoch-millis window end (exclusive). With `from` it overrides `period` (BO-F18)."
      schema: { type: integer, format: int64 }
  responses:
    BadRequest:
      description: Validation error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    Unauthorized:
      description: Missing, malformed, or expired bearer token (`error:"missing_or_invalid_token"`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    Forbidden:
      description: Authenticated, but the caller's role does not permit this action (`error:"manager_required"` or `"role_escalation_forbidden"`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    Conflict:
      description: State conflict (e.g. duplicate id, business-rule violation).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    TooManyRequests:
      description: Rate limit exceeded for this endpoint's bucket.
  schemas:
    ErrorBody:
      type: object
      properties:
        error: { type: string, description: "Machine-readable error code, e.g. `validation_error`, `manager_required`, `order_not_open`." }
        message: { type: string, nullable: true }
        correlationId: { type: string, nullable: true }
      required: [error]

    HealthStatus:
      type: object
      properties:
        status: { type: string }
        version: { type: string }
        database: { type: string }
      required: [status, version, database]

    # ── Auth ──
    LoginRequest:
      type: object
      properties:
        email: { type: string }
        password: { type: string }
        tenantId: { type: string, nullable: true, description: "Optional, and normally omitted — the server resolves the business from the credentials. Send it only after a 409 TenantChoiceResponse says the email signs in to more than one." }
      required: [email, password]
    TenantChoiceResponse:
      type: object
      description: >
        409 on POST /auth/login: the credentials are right but they identify accounts in several
        businesses (users is unique on (tenant_id, email), not on email), so the answer is a choice
        rather than a token. Retry with the chosen tenantId.
      properties:
        error: { type: string, enum: [tenant_required] }
        message: { type: string }
        tenants: { type: array, items: { $ref: "#/components/schemas/TenantOption" } }
      required: [error, message, tenants]
    TenantOption:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
      required: [id, name]
    LoginResponse:
      type: object
      properties:
        accessToken: { type: string }
        refreshToken: { type: string }
        expiresInSeconds: { type: integer, format: int64 }
        user: { $ref: "#/components/schemas/UserDto" }
      required: [accessToken, refreshToken, expiresInSeconds, user]
    RefreshRequest:
      type: object
      properties:
        refreshToken: { type: string }
      required: [refreshToken]
    UserDto:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string }
        email: { type: string }
        role: { type: string }
        locale: { type: string }
      required: [id, tenantId, email, role, locale]
    EntitlementsDto:
      type: object
      description: |
        What a tenant is licensed to run (V40/V72). Also carried on the access token as the `prd` claim.
      properties:
        products:
          type: array
          items: { type: string, enum: [RESTAURANT, RETAIL, QR_ORDERING] }
          description: "The products this tenant holds. QR_ORDERING is sold bundled with either till product or standalone."
        validUntil: { type: string, nullable: true, description: "ISO date (YYYY-MM-DD) the licence runs to; null = perpetual." }
        licenceCode: { type: string, nullable: true, description: "The key that was redeemed, for support conversations." }
      required: [products]

    # ── Catalogue ──
    CategoryDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        courseType: { type: string }
        colorHex: { type: string }
        sortOrder: { type: integer }
        active: { type: boolean }
        defaultStationId: { type: string, nullable: true, description: "Default KDS prep station id for this category (V12); null = unassigned (legacy course_type fallback)." }
      required: [id, name, courseType, colorHex, sortOrder, active]
    CategoryUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        name: { type: string }
        courseType: { type: string, default: OTHER }
        colorHex: { type: string, default: "#00BCD4" }
        sortOrder: { type: integer, default: 0 }
        active: { type: boolean, default: true }
        defaultStationId: { type: string, nullable: true, description: "Default prep station id for products in this category (V12); empty/null clears it. Must be a live station of this tenant (else 400 unknown_station)." }
      required: [name]
    PrepStationDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        sortOrder: { type: integer }
        active: { type: boolean }
      required: [id, name, sortOrder, active]
    PrepStationUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        name: { type: string }
        sortOrder: { type: integer, default: 0 }
        active: { type: boolean, default: true }
      required: [name]
    ProductDto:
      type: object
      properties:
        id: { type: string }
        categoryId: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        emoji: { type: string, nullable: true }
        imageUrl: { type: string, nullable: true }
        sku: { type: string, nullable: true }
        barcode: { type: string, nullable: true }
        courseType: { type: string }
        productType: { type: string }
        priceCents: { type: integer, format: int64 }
        costCents: { type: integer, format: int64 }
        vatType: { type: string }
        trackStock: { type: boolean }
        active: { type: boolean }
        sortOrder: { type: integer }
        allergens: { type: array, items: { type: string }, default: [], description: "Allergen names (products.allergens_json), surfaced on the QR menu + KDS." }
        brand: { type: string, nullable: true, description: "Retail metadata (V18, server-only)." }
        supplierId: { type: string, nullable: true, description: "Primary supplier (V18) — feeds PO-from-low-stock grouping." }
        stockUnit: { type: string, nullable: true, enum: [each, g, ml], description: "The unit stock is COUNTED in (V36). Base units only, so on-hand stays an exact integer. Null = undeclared: counted as items until a recipe measures it." }
      required: [id, categoryId, name, courseType, productType, priceCents, costCents, vatType, trackStock, active, sortOrder]
    ProductUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        categoryId: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        emoji: { type: string, nullable: true }
        imageUrl: { type: string, nullable: true }
        sku: { type: string, nullable: true }
        barcode: { type: string, nullable: true }
        courseType: { type: string, default: MAIN }
        productType: { type: string, default: ITEM }
        priceCents: { type: integer, format: int64 }
        costCents: { type: integer, format: int64, default: 0 }
        vatType: { type: string, default: STANDARD }
        trackStock: { type: boolean, default: false }
        active: { type: boolean, default: true }
        sortOrder: { type: integer, nullable: true, description: "Keep-on-absent for updates; inserts default to 0." }
        allergens: { type: array, items: { type: string }, nullable: true, description: "Structured allergen names. Keep-on-absent (null) on UPDATE preserves the stored list; inserts default to empty." }
        brand: { type: string, nullable: true, description: "Retail metadata (V18). Keep-on-absent; send \"\" to clear." }
        supplierId: { type: string, nullable: true, description: "Primary supplier (V18). Keep-on-absent; send \"\" to clear." }
        stockUnit: { type: string, nullable: true, enum: [each, g, ml], description: "The unit stock is counted in (V36). Keep-on-absent, so a client that does not know about units cannot un-declare one. 400 invalid_stock_unit for a non-base unit; 409 unit_in_use when changing dimension while recipes still measure this product in the old one." }
      required: [categoryId, name, priceCents]
      description: "`emoji`/`imageUrl`/`sku`/`barcode`/`sortOrder` are keep-on-absent: omitting them on an update preserves the stored values."
    ModifierOptionDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        priceDeltaCents: { type: integer, format: int64 }
        sortOrder: { type: integer }
      required: [id, name, priceDeltaCents, sortOrder]
    ModifierGroupDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        required: { type: boolean }
        multiSelect: { type: boolean }
        minSelect: { type: integer, default: 0 }
        maxSelect: { type: integer, default: 1 }
        sortOrder: { type: integer, default: 0 }
        options: { type: array, items: { $ref: "#/components/schemas/ModifierOptionDto" } }
      required: [id, name, required, multiSelect, options]
    ImageUploadRequest:
      type: object
      description: Base64 image upload (V16) — product photos (decoded ≤700KB) and location logos (decoded ≤300KB). PNG/JPEG/WebP only.
      properties:
        imageBase64: { type: string, description: "Base64-encoded image bytes." }
        mime: { type: string, default: "image/png", enum: [image/png, image/jpeg, image/webp] }
      required: [imageBase64]
    ProductPhotoResponse:
      type: object
      properties:
        id: { type: string }
        imageUrl: { type: string, nullable: true, description: "What products.image_url now holds (the internal photo path after upload; an external URL or null after delete)." }
      required: [id]
    ProductModifierLinkRequest:
      type: object
      properties:
        groupId: { type: string }
        sortOrder: { type: integer, default: 0 }
      required: [groupId]
    ProductModifierLinkDto:
      type: object
      properties:
        groupId: { type: string }
        sortOrder: { type: integer }
      required: [groupId, sortOrder]
    ModifierOptionUpsert:
      type: object
      properties:
        id: { type: string, nullable: true }
        name: { type: string }
        priceDeltaCents: { type: integer, format: int64, default: 0 }
        sortOrder: { type: integer, default: 0 }
      required: [name]
    ModifierGroupUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        name: { type: string }
        required: { type: boolean, default: false }
        multiSelect: { type: boolean, default: false }
        minSelect: { type: integer, default: 0 }
        maxSelect: { type: integer, default: 1 }
        sortOrder: { type: integer, default: 0 }
        options: { type: array, items: { $ref: "#/components/schemas/ModifierOptionUpsert" }, default: [] }
      required: [name]
    TaxRateDto:
      type: object
      properties:
        id: { type: string }
        code: { type: string }
        name: { type: string }
        rateBp: { type: integer, description: "Basis points (e.g. 1500 = 15%)." }
        inclusive: { type: boolean }
      required: [id, code, name, rateBp, inclusive]
    TaxRateUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        code: { type: string }
        name: { type: string }
        rateBp: { type: integer, minimum: 0, maximum: 10000 }
        inclusive: { type: boolean, default: true }
      required: [code, name, rateBp]

    # ── Staff ──
    StaffDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        role: { type: string }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        colorHex: { type: string, nullable: true }
        hourlyRateCents: { type: integer, format: int64 }
        active: { type: boolean }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
      required: [id, name, role, hourlyRateCents, active, createdAt, updatedAt]
    StaffCreateRequest:
      type: object
      properties:
        name: { type: string }
        role: { type: string, description: "One of ADMIN, SUPER_ADMIN, MANAGER, SERVER, CASHIER, BARTENDER, CHEF, HOST." }
        pin: { type: string, description: "4-8 digits." }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        colorHex: { type: string, nullable: true }
        hourlyRateCents: { type: integer, format: int64, default: 0 }
      required: [name, role, pin]
    StaffUpdateRequest:
      type: object
      description: All fields optional — only fields present in the body are updated.
      properties:
        name: { type: string, nullable: true }
        role: { type: string, nullable: true }
        active: { type: boolean, nullable: true }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        colorHex: { type: string, nullable: true }
        hourlyRateCents: { type: integer, format: int64, nullable: true }
    StaffResetPinRequest:
      type: object
      properties:
        pin: { type: string, description: "4-8 digits." }
      required: [pin]
    ClockInRequest:
      type: object
      properties:
        staffId: { type: string }
        locationId: { type: string }
        openingFloatCents: { type: integer, format: int64, default: 0 }
      required: [staffId, locationId]
    ClockOutRequest:
      type: object
      properties:
        shiftId: { type: string }
        breakMinutes: { type: integer, default: 0 }
        closingFloatCents: { type: integer, format: int64, nullable: true }
      required: [shiftId]
    OpenShiftDto:
      type: object
      properties:
        id: { type: string }
        staffId: { type: string }
        locationId: { type: string }
        clockInAt: { type: integer, format: int64 }
        openingFloatCents: { type: integer, format: int64 }
      required: [id, staffId, locationId, clockInAt, openingFloatCents]
    ShiftCloseRequest:
      type: object
      description: Optional body for the manager force clock-out. Absent closingFloatCents leaves the stored value untouched.
      properties:
        closingFloatCents: { type: integer, format: int64, nullable: true }
    ShiftCloseResponse:
      type: object
      properties:
        id: { type: string }
        status: { type: string, example: CLOSED }
        clockOutAt: { type: integer, format: int64 }
      required: [id, status, clockOutAt]

    # ── Locations ──
    LocationDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        vatNumber: { type: string, nullable: true }
        timezone: { type: string, example: Africa/Johannesburg }
        currency: { type: string, example: ZAR }
        receiptHeader: { type: string, nullable: true, description: "Receipt branding header lines." }
        receiptFooter: { type: string, nullable: true, description: "Receipt branding footer lines." }
        hasLogo: { type: boolean, description: "True when a logo is stored (fetch via public GET /locations/{id}/logo)." }
        businessType: { type: string, enum: [HOSPITALITY, RETAIL], default: HOSPITALITY, description: "Vertical (V17) — drives POS shop mode." }
      required: [id, name, timezone, currency, hasLogo]
    LocationUpsertRequest:
      type: object
      description: Optional fields are keep-on-absent on UPDATE; on INSERT timezone/currency default to Africa/Johannesburg + ZAR.
      properties:
        id: { type: string, nullable: true, description: "Absent = create with a server-minted id." }
        name: { type: string }
        address: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        vatNumber: { type: string, nullable: true }
        receiptHeader: { type: string, nullable: true }
        receiptFooter: { type: string, nullable: true }
        timezone: { type: string, nullable: true }
        currency: { type: string, nullable: true }
        businessType: { type: string, nullable: true, enum: [HOSPITALITY, RETAIL], description: "Keep-on-absent on update; HOSPITALITY on insert; invalid value → 400 invalid_business_type." }
      required: [name]
    LocationLogoResponse:
      type: object
      properties:
        id: { type: string }
        hasLogo: { type: boolean }
      required: [id, hasLogo]

    # ── Floor & Tables ──
    TableDto:
      type: object
      properties:
        id: { type: string }
        locationId: { type: string }
        zoneId: { type: string }
        label: { type: string }
        seats: { type: integer }
        shape: { type: string }
        status: { type: string }
        currentOrderId: { type: string, nullable: true }
        guestCount: { type: integer }
        seatedAt: { type: integer, format: int64, nullable: true }
        serverStaffId: { type: string, nullable: true }
        positionX: { type: integer, default: 0 }
        positionY: { type: integer, default: 0 }
      required: [id, locationId, zoneId, label, seats, shape, status, guestCount]
    TableUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        locationId: { type: string }
        zoneId: { type: string }
        label: { type: string, description: "Must be unique per location among live tables (409 duplicate_label)." }
        seats: { type: integer, default: 2 }
        positionX: { type: integer, nullable: true }
        positionY: { type: integer, nullable: true }
        shape: { type: string, nullable: true }
      required: [locationId, zoneId, label]
      description: "`positionX`/`positionY`/`shape` are keep-on-absent: omitting them on an update preserves the stored floor-plan layout (inserts default to 0,0/ROUND)."
    TableSeatRequest:
      type: object
      properties:
        guestCount: { type: integer }
        serverStaffId: { type: string, nullable: true }
      required: [guestCount]
    TableTransferRequest:
      type: object
      properties:
        toTableId: { type: string }
      required: [toTableId]
    TableMergeRequest:
      type: object
      properties:
        intoTableId: { type: string }
      required: [intoTableId]
    ZoneDto:
      type: object
      properties:
        id: { type: string }
        locationId: { type: string }
        name: { type: string }
        sortOrder: { type: integer }
      required: [id, locationId, name, sortOrder]
    ZoneUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        locationId: { type: string }
        name: { type: string }
        sortOrder: { type: integer, default: 0 }
      required: [locationId, name]
    ReservationDto:
      type: object
      properties:
        id: { type: string }
        locationId: { type: string }
        tableId: { type: string, nullable: true }
        guestName: { type: string }
        partySize: { type: integer }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        reservedForAt: { type: integer, format: int64 }
        durationMinutes: { type: integer }
        status: { type: string }
      required: [id, locationId, guestName, partySize, reservedForAt, durationMinutes, status]
    ReservationUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        locationId: { type: string }
        tableId: { type: string, nullable: true }
        guestName: { type: string }
        partySize: { type: integer }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        reservedForAt: { type: integer, format: int64 }
        durationMinutes: { type: integer, default: 90 }
        status: { type: string, default: PENDING }
      required: [locationId, guestName, partySize, reservedForAt]

    # ── Waitlist ──
    WaitlistEntryDto:
      type: object
      properties:
        id: { type: string }
        locationId: { type: string }
        guestName: { type: string }
        partySize: { type: integer }
        phone: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        status: { type: string }
        createdAt: { type: integer, format: int64 }
        position: { type: integer }
        waitedMinutes: { type: integer }
        quotedWaitMinutes: { type: integer }
      required: [id, locationId, guestName, partySize, status, createdAt, position, waitedMinutes, quotedWaitMinutes]
    WaitlistAddRequest:
      type: object
      properties:
        locationId: { type: string }
        guestName: { type: string }
        partySize: { type: integer }
        phone: { type: string, nullable: true }
        notes: { type: string, nullable: true }
      required: [locationId, guestName, partySize]
    WaitlistSeatRequest:
      type: object
      properties:
        tableId: { type: string }
        serverStaffId: { type: string, nullable: true }
      required: [tableId]
    WaitlistRemoveRequest:
      type: object
      properties:
        status: { type: string, default: CANCELLED, description: "CANCELLED or NOSHOW." }

    # ── Orders ──
    OrderDto:
      type: object
      properties:
        id: { type: string }
        transactionNumber: { type: string }
        locationId: { type: string }
        tableId: { type: string, nullable: true }
        customerId: { type: string, nullable: true }
        serverStaffId: { type: string, nullable: true }
        orderType: { type: string }
        status: { type: string }
        covers: { type: integer, nullable: true, description: "Party size on the check (V11); null when unknown (bar/takeaway)." }
        subtotalCents: { type: integer, format: int64 }
        discountCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64 }
        tipCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        openedAt: { type: integer, format: int64 }
        closedAt: { type: integer, format: int64, nullable: true }
        courseMode: { type: string, enum: [PARALLEL, MANUAL, SEQUENTIAL, HYBRID], default: PARALLEL, description: "V13 — how the order's courses reach the kitchen." }
        saleKind: { type: string, enum: [STANDARD, LAYBY], default: STANDARD, description: "V18 — laybys stay OPEN accumulating paidCents until settled." }
        activeCourse: { type: string, nullable: true, description: "Course currently on the pass (list route only); null when nothing is firing." }
        completedCourse: { type: string, nullable: true, description: "Last fully-bumped course (list route only)." }
        origin: { type: string, nullable: true, description: "\"QR\" for guest self-orders (V72), null/omitted for till-authored ones — so the POS can badge self-orders and show their paid state." }
      required: [id, transactionNumber, locationId, orderType, status, subtotalCents, discountCents, vatCents, tipCents, totalCents, paidCents, openedAt]
    OrderCreateRequest:
      type: object
      properties:
        locationId: { type: string }
        tableId: { type: string, nullable: true }
        customerId: { type: string, nullable: true }
        serverStaffId: { type: string, nullable: true }
        cashierStaffId: { type: string, nullable: true }
        orderType: { type: string, default: DINE_IN }
        notes: { type: string, nullable: true }
        deviceId: { type: string }
      required: [locationId, deviceId]
    OrderLineModifier:
      type: object
      properties:
        groupId: { type: string }
        optionId: { type: string }
        nameSnapshot: { type: string }
        priceDeltaCents: { type: integer, format: int64 }
      required: [groupId, optionId, nameSnapshot, priceDeltaCents]
    OrderLineRequest:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        nameSnapshot: { type: string }
        courseType: { type: string, default: MAIN }
        quantity: { type: integer, default: 1 }
        unitPriceCents: { type: integer, format: int64, description: "Fallback only — the server re-prices from the catalogue." }
        vatType: { type: string, default: STANDARD }
        modifiers: { type: array, items: { $ref: "#/components/schemas/OrderLineModifier" }, default: [] }
        discountCents: { type: integer, format: int64, default: 0 }
        note: { type: string, nullable: true }
        seatNumber: { type: integer, nullable: true, description: "1..N; null = shared (F-05)." }
      required: [productId, nameSnapshot, unitPriceCents]
    OrderLineDto:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        name: { type: string }
        quantity: { type: integer }
        unitPriceCents: { type: integer, format: int64 }
        lineTotalCents: { type: integer, format: int64 }
        seatNumber: { type: integer, nullable: true, description: "1..N; null = shared (F-05)." }
        courseType: { type: string }
        note: { type: string, nullable: true }
        voided: { type: boolean }
        comped: { type: boolean }
      required: [id, productId, name, quantity, unitPriceCents, lineTotalCents, courseType, voided, comped]
    OrderLinesResponse:
      type: object
      properties:
        orderId: { type: string }
        status: { type: string }
        subtotalCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64 }
        discountCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/OrderLineDto" } }
      required: [orderId, status, subtotalCents, vatCents, discountCents, totalCents, paidCents, lines]
    OrderCustomerAttachRequest:
      type: object
      properties:
        customerId: { type: string }
      required: [customerId]
    PaymentLeg:
      type: object
      properties:
        method: { type: string, description: "YOCO_CARD | CASH | EFT | SASSA | SNAPSCAN | ZAPPER | VOUCHER" }
        amountCents: { type: integer, format: int64 }
        tipCents: { type: integer, format: int64, default: 0 }
        tenderedCents: { type: integer, format: int64, nullable: true }
        providerReference: { type: string, nullable: true }
        last4: { type: string, nullable: true }
      required: [method, amountCents]
    CheckoutRequest:
      type: object
      properties:
        orderId: { type: string, description: "Must match the {id} path parameter." }
        payments: { type: array, items: { $ref: "#/components/schemas/PaymentLeg" } }
        tipCents: { type: integer, format: int64, default: 0 }
        tipStaffId: { type: string, nullable: true }
      required: [orderId, payments]
    CheckoutResult:
      type: object
      properties:
        orderId: { type: string }
        transactionNumber: { type: string }
        subtotalCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64 }
        discountCents: { type: integer, format: int64 }
        tipCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        paymentIds: { type: array, items: { type: string } }
        journalEntryId: { type: string }
      required: [orderId, transactionNumber, subtotalCents, vatCents, discountCents, tipCents, totalCents, paidCents, paymentIds, journalEntryId]
    FireRequest:
      type: object
      properties:
        course: { type: string, default: ALL, description: "STARTER | MAIN | DESSERT | DRINK | SIDE | ALL" }
        rush: { type: boolean, default: false }
        firedByStaffId: { type: string, nullable: true }
        station: { type: string, nullable: true, description: "Override; otherwise resolved per-product." }
    TicketRef:
      type: object
      properties:
        ticketId: { type: string }
        station: { type: string, nullable: true }
        items: { type: integer }
      required: [ticketId, items]
    FireResult:
      type: object
      properties:
        tickets: { type: array, items: { $ref: "#/components/schemas/TicketRef" } }
        itemCount: { type: integer }
      required: [tickets, itemCount]
    SplitRequest:
      type: object
      properties:
        orderId: { type: string, description: "Must match the {id} path parameter." }
        method: { type: string, description: "EVEN | BY_ITEM | BY_SEAT | CUSTOM" }
        guestCount: { type: integer, minimum: 2, maximum: 50 }
        itemAssignments: { type: object, additionalProperties: { type: integer }, nullable: true, description: "lineId → portion (1..guestCount). Required for BY_ITEM." }
        customAmounts: { type: array, items: { type: integer, format: int64 }, nullable: true, description: "Size = guestCount, cents per portion. Required for CUSTOM." }
      required: [orderId, method, guestCount]
    SplitPortion:
      type: object
      properties:
        portion: { type: integer }
        amountCents: { type: integer, format: int64 }
        lineIds: { type: array, items: { type: string }, default: [] }
      required: [portion, amountCents]
    SplitResult:
      type: object
      properties:
        splitId: { type: string }
        orderId: { type: string }
        method: { type: string }
        portions: { type: array, items: { $ref: "#/components/schemas/SplitPortion" } }
        totalCents: { type: integer, format: int64 }
      required: [splitId, orderId, method, portions, totalCents]
    VoidRequest:
      type: object
      properties:
        transactionId: { type: string }
        lineId: { type: string, nullable: true, description: "Omit to void the whole order." }
        reason: { type: string }
        approvedByStaffId: { type: string }
      required: [transactionId, reason, approvedByStaffId]
    CompRequest:
      type: object
      properties:
        transactionId: { type: string }
        lineId: { type: string }
        reason: { type: string }
        amountCents: { type: integer, format: int64 }
        approvedByStaffId: { type: string }
      required: [transactionId, lineId, reason, amountCents, approvedByStaffId]
    DiscountApplyRequest:
      type: object
      properties:
        transactionId: { type: string }
        lineId: { type: string, nullable: true }
        discountType: { type: string }
        value: { type: integer, format: int64 }
        amountCents: { type: integer, format: int64 }
        reasonCode: { type: string }
        reasonNote: { type: string, nullable: true }
        approvedByStaffId: { type: string, nullable: true }
      required: [transactionId, discountType, value, amountCents, reasonCode]

    # ── Kitchen ──
    KitchenItemDto:
      type: object
      properties:
        name: { type: string }
        quantity: { type: integer }
        note: { type: string, nullable: true }
      required: [name, quantity]
    KitchenTicketDto:
      type: object
      properties:
        id: { type: string }
        tableLabel: { type: string, nullable: true }
        courseType: { type: string }
        status: { type: string }
        rush: { type: boolean }
        firedAt: { type: integer, format: int64, description: "Epoch millis the ticket fired — clients can tick age locally." }
        elapsedSec: { type: integer, description: "Server-computed age ((now - firedAt)/1000) at response time." }
        server: { type: string, nullable: true }
        itemsSummary: { type: string, description: "Human-readable one-liner (back-compat); prefer `items`." }
        items:
          type: array
          items: { $ref: "#/components/schemas/KitchenItemDto" }
        station: { type: string, nullable: true, description: "Prep station display name (V12); legacy/fallback tickets carry a code: BAR | HOT | PASTRY | MAIN." }
        stationId: { type: string, nullable: true, description: "Resolved prep-station id (V12) — drives station filtering; null for legacy/fallback tickets." }
        transactionNumber: { type: string, nullable: true, description: "Snapshot from the backing transaction." }
        orderType: { type: string, nullable: true, description: "DINE_IN | TAKEAWAY | BAR_TAB | ... from the backing transaction." }
        allergens: { type: array, items: { type: string } }
        fulfilmentType: { type: string, nullable: true, description: "Pickup indicator (V10): DINE_IN | TAKEAWAY. Null = eat-in." }
        scheduledForMillis: { type: integer, format: int64, nullable: true, description: "Scheduled pickup time (epoch millis) for a takeaway order-for-later; null = as-soon-as-ready." }
        covers: { type: integer, nullable: true, description: "Guest count on the check (V11); null when unknown (bar/takeaway)." }
        autoFired: { type: boolean, description: "Fired by SEQUENTIAL course auto-advance (V14) — the KDS device prints these prep tickets." }
        origin: { type: string, nullable: true, description: "\"QR\" when the backing order is a guest self-order (V72) — the KDS badges these. Omitted otherwise." }
        readyAt: { type: integer, format: int64, nullable: true, description: "When the pass first had the food (V15): set by /ready, backfilled by bump/serve. Omitted when null." }
        bumpedAt: { type: integer, format: int64, nullable: true, description: "When the kitchen bumped it. Omitted when null." }
        servedAt: { type: integer, format: int64, nullable: true, description: "When the floor confirmed delivery (V15). Null/omitted = still to run." }
        doneIndexes: { type: array, items: { type: integer }, description: "Shared per-item prep ticks (V15) — indexes into `items`. Omitted when empty." }
      required: [id, courseType, status, rush, firedAt, elapsedSec, itemsSummary]

    # ── Tabs ──
    TabDto:
      type: object
      properties:
        id: { type: string }
        label: { type: string }
        transactionId: { type: string }
        status: { type: string }
        openedAt: { type: integer, format: int64 }
        customerId: { type: string, nullable: true }
        preauthAmountCents: { type: integer, format: int64, nullable: true }
      required: [id, label, transactionId, status, openedAt]
    TabOpenRequest:
      type: object
      properties:
        locationId: { type: string }
        label: { type: string }
        customerId: { type: string, nullable: true }
        customerPhone: { type: string, nullable: true }
        serverStaffId: { type: string, nullable: true }
        orderType: { type: string, default: BAR_TAB }
        deviceId: { type: string }
      required: [locationId, label, deviceId]
    TabPreauthRequest:
      type: object
      properties:
        amountCents: { type: integer, format: int64 }
        yocoCheckoutId: { type: string, nullable: true }
      required: [amountCents]
    TabSettleRequest:
      type: object
      properties:
        payments: { type: array, items: { $ref: "#/components/schemas/PaymentLeg" } }
        tipCents: { type: integer, format: int64, default: 0 }
      required: [payments]

    # ── Payments / Refunds ──
    PaymentRequest:
      type: object
      properties:
        transactionId: { type: string }
        method: { type: string, description: "YOCO_CARD | CASH | EFT | SASSA | SNAPSCAN | ZAPPER | VOUCHER" }
        amountCents: { type: integer, format: int64 }
        tipCents: { type: integer, format: int64, default: 0 }
        tenderedCents: { type: integer, format: int64, nullable: true }
        providerReference: { type: string, nullable: true }
        last4: { type: string, nullable: true }
        idempotencyKey: { type: string, nullable: true, description: "Client-generated key; a retried request with the same key replays the first result instead of charging twice." }
      required: [transactionId, method, amountCents]
    PaymentResponse:
      type: object
      properties:
        paymentId: { type: string }
        status: { type: string }
        providerReference: { type: string, nullable: true }
        checkoutUrl: { type: string, nullable: true, description: "Yoco — URL the cashier must redirect to." }
        qrPayload: { type: string, nullable: true, description: "SnapScan / Zapper QR string." }
        expiresAt: { type: integer, format: int64, nullable: true }
        receiptUrl: { type: string, nullable: true, description: "Signed public digital-receipt link, set once captured." }
      required: [paymentId, status]
    RefundRequest:
      type: object
      properties:
        transactionId: { type: string }
        amountCents: { type: integer, format: int64, nullable: true, description: "Omit for a full refund." }
        reason: { type: string }
        staffId: { type: string }
        toStoreCredit: { type: boolean, default: false, description: "V18: skip the provider and credit the sale's customer instead (400 store_credit_needs_customer when none attached)." }
      required: [transactionId, reason, staffId]
    ProviderRefund:
      type: object
      properties:
        paymentId: { type: string }
        method: { type: string }
        providerRefundId: { type: string, nullable: true }
        status: { type: string }
      required: [paymentId, method, status]
    RefundResult:
      type: object
      properties:
        refundTransactionId: { type: string }
        refundNumber: { type: string }
        amountCents: { type: integer, format: int64 }
        providerRefunds: { type: array, items: { $ref: "#/components/schemas/ProviderRefund" } }
        journalEntryId: { type: string }
        originalStatus: { type: string }
      required: [refundTransactionId, refundNumber, amountCents, providerRefunds, journalEntryId, originalStatus]

    # ── Webhooks ──
    WebhookAck:
      type: object
      properties:
        received: { type: boolean, default: true }
        eventId: { type: string, nullable: true }

    # ── Sync ──
    SyncMutation:
      type: object
      properties:
        id: { type: string }
        entityType: { type: string }
        entityId: { type: string }
        operation: { type: string }
        payloadJson: { type: string, description: "JSON-encoded entity payload." }
        deviceId: { type: string }
        enqueuedAt: { type: integer, format: int64 }
      required: [id, entityType, entityId, operation, payloadJson, deviceId, enqueuedAt]
    SyncPushRequest:
      type: object
      properties:
        mutations: { type: array, items: { $ref: "#/components/schemas/SyncMutation" } }
      required: [mutations]
    SyncResult:
      type: object
      properties:
        id: { type: string }
        accepted: { type: boolean }
        error: { type: string, nullable: true }
      required: [id, accepted]
    SyncPushResponse:
      type: object
      properties:
        results: { type: array, items: { $ref: "#/components/schemas/SyncResult" } }
      required: [results]
    SyncEntity:
      type: object
      properties:
        entityType: { type: string }
        entityId: { type: string }
        payloadJson: { type: string }
        syncVersion: { type: integer, format: int64 }
      required: [entityType, entityId, payloadJson, syncVersion]
    SyncPullResponse:
      type: object
      properties:
        entities: { type: array, items: { $ref: "#/components/schemas/SyncEntity" }, default: [] }
        nextCursor: { type: integer, format: int64 }
      required: [nextCursor]
    SyncQueueCount:
      type: object
      properties:
        pending: { type: integer }
        failed: { type: integer }
      required: [pending, failed]

    # ── Reports ──
    HourlyBucket:
      type: object
      properties:
        hour: { type: integer }
        revenueCents: { type: integer, format: int64 }
        ordersCount: { type: integer }
      required: [hour, revenueCents, ordersCount]
    TopProduct:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        quantity: { type: integer }
        revenueCents: { type: integer, format: int64 }
      required: [productId, name, quantity, revenueCents]
    DashboardReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        revenueCents: { type: integer, format: int64 }
        coversCount: { type: integer }
        averageCheckCents: { type: integer, format: int64 }
        tipsCents: { type: integer, format: int64 }
        activeTables: { type: integer }
        hourlyRevenue: { type: array, items: { $ref: "#/components/schemas/HourlyBucket" } }
        paymentMix: { type: object, additionalProperties: { type: integer, format: int64 } }
        topProducts: { type: array, items: { $ref: "#/components/schemas/TopProduct" } }
      required: [periodStart, periodEnd, revenueCents, coversCount, averageCheckCents, tipsCents, activeTables, hourlyRevenue, paymentMix, topProducts]
    XReport:
      type: object
      properties:
        shiftId: { type: string }
        openedAt: { type: integer, format: int64 }
        asOf: { type: integer, format: int64 }
        locked: { type: boolean, default: false }
        ordersCount: { type: integer }
        grossCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64 }
        discountsCents: { type: integer, format: int64 }
        refundsCents: { type: integer, format: int64 }
        voidsCents: { type: integer, format: int64 }
        compsCents: { type: integer, format: int64 }
        tipsCents: { type: integer, format: int64 }
        paymentMix: { type: object, additionalProperties: { type: integer, format: int64 } }
        openingFloatCents: { type: integer, format: int64, default: 0 }
        paidInCents: { type: integer, format: int64, default: 0 }
        paidOutCents: { type: integer, format: int64, default: 0 }
        expectedCashCents: { type: integer, format: int64, default: 0, description: "openingFloat + cash sales + paid-in − paid-out" }
      required: [shiftId, openedAt, asOf, ordersCount, grossCents, vatCents, discountsCents, refundsCents, voidsCents, compsCents, tipsCents, paymentMix]
    ZReportRequest:
      type: object
      properties:
        shiftId: { type: string }
        closingFloatCents: { type: integer, format: int64 }
        approverStaffId: { type: string }
        countedBreakdownJson: { type: string, nullable: true, description: "Denomination count at close — JSON map of denomination-cents to qty, e.g. {\"20000\":3}" }
      required: [shiftId, closingFloatCents, approverStaffId]
    CashMovementRequest:
      type: object
      properties:
        movementType: { type: string, enum: [PAID_IN, PAID_OUT, NO_SALE] }
        amountCents: { type: integer, format: int64, default: 0, description: "Positive; must be 0 for NO_SALE, >0 otherwise" }
        reason: { type: string }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true, description: "Defaults to the shift's staffer" }
      required: [movementType, reason]
    CashMovementDto:
      type: object
      properties:
        id: { type: string }
        shiftId: { type: string }
        locationId: { type: string }
        staffId: { type: string, nullable: true }
        movementType: { type: string, enum: [PAID_IN, PAID_OUT, NO_SALE] }
        amountCents: { type: integer, format: int64 }
        reason: { type: string }
        note: { type: string, nullable: true }
        occurredAt: { type: integer, format: int64 }
      required: [id, shiftId, locationId, movementType, amountCents, reason, occurredAt]
    ShiftSummaryDto:
      type: object
      properties:
        id: { type: string }
        staffId: { type: string }
        locationId: { type: string }
        clockInAt: { type: integer, format: int64 }
        clockOutAt: { type: integer, format: int64, nullable: true }
        status: { type: string }
        openingFloatCents: { type: integer, format: int64 }
        expectedCashCents: { type: integer, format: int64, nullable: true }
        countedCashCents: { type: integer, format: int64, nullable: true }
        cashVarianceCents: { type: integer, format: int64, nullable: true }
        zReportGenerated: { type: boolean, default: false }
        paidInCents: { type: integer, format: int64, default: 0 }
        paidOutCents: { type: integer, format: int64, default: 0 }
      required: [id, staffId, locationId, clockInAt, status, openingFloatCents]
    VariantDto:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        name: { type: string }
        priceDeltaCents: { type: integer, format: int64, description: "Signed delta off the parent product price" }
        sku: { type: string, nullable: true }
        sortOrder: { type: integer, default: 0 }
        active: { type: boolean, default: true }
      required: [id, productId, name, priceDeltaCents]
    VariantUpsertRequest:
      type: object
      properties:
        id: { type: string, nullable: true }
        name: { type: string }
        priceDeltaCents: { type: integer, format: int64, default: 0 }
        sku: { type: string, nullable: true, description: "Keep-on-absent on update; blank clears" }
        sortOrder: { type: integer, nullable: true, description: "Keep-on-absent on update" }
        active: { type: boolean, default: true }
      required: [name]
    LabelSheetRequest:
      type: object
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/LabelSheetItem" } }
      required: [items]
    LabelSheetItem:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        copies: { type: integer, default: 1, minimum: 1, maximum: 100 }
      required: [productId]
    LaybyOpenRequest:
      type: object
      properties:
        customerId: { type: string, nullable: true, description: "Falls back to the order's attached customer" }
        deposit: { $ref: "#/components/schemas/PaymentLeg" }
        byStaffId: { type: string, nullable: true }
    LaybyPaymentRequest:
      type: object
      properties:
        leg: { $ref: "#/components/schemas/PaymentLeg" }
        byStaffId: { type: string, nullable: true }
      required: [leg]
    LaybyPaymentResponse:
      type: object
      properties:
        orderId: { type: string }
        paidCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        balanceDueCents: { type: integer, format: int64 }
        settled: { type: boolean, description: "True once installments cover the total — the sale is now PAID" }
      required: [orderId, paidCents, totalCents, balanceDueCents, settled]
    StoreCreditBalance:
      type: object
      properties:
        customerId: { type: string }
        storeCreditCents: { type: integer, format: int64 }
      required: [customerId, storeCreditCents]
    StoreCreditAdjustRequest:
      type: object
      properties:
        deltaCents: { type: integer, format: int64, description: "Signed; non-zero" }
        reason: { type: string }
        byStaffId: { type: string, nullable: true }
      required: [deltaCents, reason]
    StoreCreditRedeemRequest:
      type: object
      properties:
        amountCents: { type: integer, format: int64, minimum: 1 }
        orderId: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [amountCents]
    StoreCreditMovementDto:
      type: object
      properties:
        id: { type: string }
        customerId: { type: string }
        deltaCents: { type: integer, format: int64 }
        reason: { type: string }
        referenceId: { type: string, nullable: true }
        staffId: { type: string, nullable: true }
        occurredAt: { type: integer, format: int64 }
      required: [id, customerId, deltaCents, reason, occurredAt]
    StockTransferDto:
      type: object
      properties:
        id: { type: string }
        transferNumber: { type: string }
        status: { type: string, enum: [DRAFT, IN_TRANSIT, RECEIVED, CANCELLED] }
        fromLocationId: { type: string }
        toLocationId: { type: string }
        sentAt: { type: integer, format: int64, nullable: true }
        receivedAt: { type: integer, format: int64, nullable: true }
        notes: { type: string, nullable: true }
        createdByStaffId: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/StockTransferLineDto" } }
      required: [id, transferNumber, status, fromLocationId, toLocationId, createdAt]
    StockTransferLineDto:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        productName: { type: string, nullable: true }
        quantityUnits: { type: integer, format: int64 }
        receivedUnits: { type: integer, format: int64, default: 0 }
      required: [id, productId, quantityUnits]
    StockTransferCreateRequest:
      type: object
      properties:
        fromLocationId: { type: string }
        toLocationId: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              productId: { type: string }
              quantityUnits: { type: integer, format: int64, minimum: 1 }
            required: [productId, quantityUnits]
        notes: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [fromLocationId, toLocationId, lines]
    StockTransferReceiveRequest:
      type: object
      properties:
        receiveAll: { type: boolean, default: true, description: "True receives every line in full, ignoring lines[]" }
        lines:
          type: array
          items:
            type: object
            properties:
              lineId: { type: string }
              receivedUnits: { type: integer, format: int64, minimum: 0 }
            required: [lineId, receivedUnits]
    ProductsReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        rows: { type: array, items: { $ref: "#/components/schemas/ProductsReportRow" } }
      required: [periodStart, periodEnd, rows]
    ProductsReportRow:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        categoryId: { type: string, nullable: true }
        categoryName: { type: string, nullable: true }
        brand: { type: string, nullable: true }
        quantity: { type: integer, format: int64 }
        revenueCents: { type: integer, format: int64 }
        costCents: { type: integer, format: int64, description: "Recipe-aware unit cost × quantity" }
        marginCents: { type: integer, format: int64 }
      # costCents and marginCents are withheld from non-managers — a supplier's terms are
      # not a cashier's business — and are omitted from the payload rather than zeroed,
      # because a zero is a figure somebody quotes. Managers get both.
      required: [productId, name, quantity, revenueCents]
    CustomersReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        totalSalesCount: { type: integer }
        salesWithCustomer: { type: integer }
        uniqueCustomers: { type: integer }
        newCustomers: { type: integer, description: "First-ever sale falls inside the window" }
        returningCustomers: { type: integer }
        averageBasketCents: { type: integer, format: int64 }
        averageBasketWithCustomerCents: { type: integer, format: int64 }
        repeatRatePercent: { type: integer, description: "Share of window customers with 2+ sales in the window" }
        topCustomers: { type: array, items: { $ref: "#/components/schemas/TopCustomerRow" } }
      required: [periodStart, periodEnd, totalSalesCount, salesWithCustomer, uniqueCustomers, newCustomers, returningCustomers, averageBasketCents, averageBasketWithCustomerCents, repeatRatePercent, topCustomers]
    TopCustomerRow:
      type: object
      properties:
        customerId: { type: string }
        name: { type: string }
        salesCount: { type: integer }
        revenueCents: { type: integer, format: int64 }
        lastSaleAt: { type: integer, format: int64 }
      required: [customerId, name, salesCount, revenueCents, lastSaleAt]
    InventoryAgingReport:
      type: object
      properties:
        locationId: { type: string }
        olderThanDays: { type: integer }
        rows: { type: array, items: { $ref: "#/components/schemas/InventoryAgingRow" } }
      required: [locationId, olderThanDays, rows]
    InventoryAgingRow:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        onHandUnits: { type: integer, format: int64 }
        stockValueCents: { type: integer, format: int64 }
        lastSoldAt: { type: integer, format: int64, nullable: true, description: "Null = never sold" }
        daysSinceLastSale: { type: integer, nullable: true }
      # stockValueCents is stock at cost: manager-only, and omitted otherwise. The row
      # order is gated with it — sorting by value hands over the cost ranking with the
      # numbers filed off.
      required: [productId, name, onHandUnits]
    ZReport:
      type: object
      properties:
        xReport: { $ref: "#/components/schemas/XReport" }
        cashDropCents: { type: integer, format: int64 }
        journalEntryId: { type: string, nullable: true }
        expectedCashCents: { type: integer, format: int64, default: 0 }
        cashVarianceCents: { type: integer, format: int64, default: 0 }
      required: [xReport, cashDropCents]
    Vat201Report:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        standardSalesCents: { type: integer, format: int64 }
        standardOutputVatCents: { type: integer, format: int64 }
        zeroRatedSalesCents: { type: integer, format: int64 }
        exemptSalesCents: { type: integer, format: int64 }
        totalSalesCents: { type: integer, format: int64 }
        inputVatCents: { type: integer, format: int64 }
        vatPayableCents: { type: integer, format: int64 }
      required: [periodStart, periodEnd, standardSalesCents, standardOutputVatCents, zeroRatedSalesCents, exemptSalesCents, totalSalesCents, inputVatCents, vatPayableCents]
    HeatmapCell:
      type: object
      properties:
        hour: { type: integer }
        revenueCents: { type: integer, format: int64 }
      required: [hour, revenueCents]
    HeatmapRow:
      type: object
      properties:
        dayOfWeek: { type: integer, description: "0=Sunday .. 6=Saturday" }
        buckets: { type: array, items: { $ref: "#/components/schemas/HeatmapCell" } }
      required: [dayOfWeek, buckets]
    HeatmapReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        rows: { type: array, items: { $ref: "#/components/schemas/HeatmapRow" } }
      required: [periodStart, periodEnd, rows]
    ServerPerformanceRow:
      type: object
      properties:
        staffId: { type: string }
        staffName: { type: string, nullable: true, description: "Resolved staff display name; null if the staff row no longer exists." }
        ordersCount: { type: integer }
        grossCents: { type: integer, format: int64 }
        tipsCents: { type: integer, format: int64 }
        avgCheckCents: { type: integer, format: int64 }
      required: [staffId, ordersCount, grossCents, tipsCents, avgCheckCents]
    ServerPerformanceReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        rows: { type: array, items: { $ref: "#/components/schemas/ServerPerformanceRow" } }
      required: [periodStart, periodEnd, rows]
    MenuEngineeringItem:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        unitsSold: { type: integer, format: int64 }
        grossRevenueCents: { type: integer, format: int64 }
        costCents: { type: integer, format: int64, description: "Recipe-derived (or product) cost × units." }
        marginCents: { type: integer, format: int64 }
        marginPct: { type: number, format: double }
        unitMarginCents: { type: integer, format: int64 }
        popularityRank: { type: integer, description: "1 = most units sold." }
        quadrant: { type: string, description: "STAR | PLOWHORSE | PUZZLE | DOG" }
      # The quadrant is the point of this report for floor staff — it says which dish to
      # recommend — so it is served to everybody. The money is not: costCents, marginCents,
      # unitMarginCents and marginPct are manager-only. marginPct is withheld too, because a
      # percentage plus a price is a cost.
      required: [productId, name, unitsSold, grossRevenueCents, popularityRank, quadrant]
    MenuEngineeringReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        locationId: { type: string, nullable: true }
        medianUnitsSold: { type: integer, format: int64 }
        medianUnitMarginCents: { type: integer, format: int64 }
        items: { type: array, items: { $ref: "#/components/schemas/MenuEngineeringItem" } }
      # medianUnitMarginCents is a margin in cents and would have defeated the gate.
      required: [periodStart, periodEnd, medianUnitsSold, items]
    PacingStat:
      type: object
      properties:
        avgMs: { type: integer, format: int64 }
        p90Ms: { type: integer, format: int64 }
        count: { type: integer }
      required: [avgMs, p90Ms, count]
    PacingStationStat:
      type: object
      properties:
        station: { type: string }
        avgFireToBumpMs: { type: integer, format: int64 }
        p90FireToBumpMs: { type: integer, format: int64 }
        ticketCount: { type: integer }
      required: [station, avgFireToBumpMs, p90FireToBumpMs, ticketCount]
    PacingCourseStat:
      type: object
      properties:
        courseType: { type: string }
        avgFireToBumpMs: { type: integer, format: int64 }
        p90FireToBumpMs: { type: integer, format: int64 }
        ticketCount: { type: integer }
      required: [courseType, avgFireToBumpMs, p90FireToBumpMs, ticketCount]
    PacingReport:
      type: object
      properties:
        periodStart: { type: integer, format: int64 }
        periodEnd: { type: integer, format: int64 }
        locationId: { type: string, nullable: true }
        availableMetrics: { type: array, items: { type: string } }
        unavailableMetrics: { type: array, items: { type: string } }
        seatedToFirstOrder: { $ref: "#/components/schemas/PacingStat" }
        orderToFire: { $ref: "#/components/schemas/PacingStat" }
        fireToBump: { $ref: "#/components/schemas/PacingStat" }
        orderToSettle: { $ref: "#/components/schemas/PacingStat" }
        slowestStations: { type: array, items: { $ref: "#/components/schemas/PacingStationStat" } }
        slowestCourses: { type: array, items: { $ref: "#/components/schemas/PacingCourseStat" } }
        longIdleOccupiedTables: { type: integer }
        idleThresholdMinutes: { type: integer }
      required: [periodStart, periodEnd, availableMetrics, unavailableMetrics, orderToFire, fireToBump, orderToSettle, slowestStations, slowestCourses, longIdleOccupiedTables, idleThresholdMinutes]
    DiscountLogEntry:
      type: object
      properties:
        id: { type: string }
        transactionId: { type: string }
        discountType: { type: string }
        amountCents: { type: integer, format: int64 }
        reasonCode: { type: string }
        reasonNote: { type: string, nullable: true }
        approvedByStaffId: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
      required: [id, transactionId, discountType, amountCents, reasonCode, createdAt]
    VoidLogEntry:
      type: object
      properties:
        id: { type: string }
        transactionId: { type: string }
        lineId: { type: string, nullable: true }
        reason: { type: string }
        approvedByStaffId: { type: string }
        voidedAt: { type: integer, format: int64 }
      required: [id, transactionId, reason, approvedByStaffId, voidedAt]
    CompLogEntry:
      type: object
      properties:
        id: { type: string }
        transactionId: { type: string }
        lineId: { type: string }
        reason: { type: string }
        amountCents: { type: integer, format: int64 }
        approvedByStaffId: { type: string }
        compedAt: { type: integer, format: int64 }
      required: [id, transactionId, lineId, reason, amountCents, approvedByStaffId, compedAt]

    # ── Ledgr ──
    JournalGenerateRequest:
      type: object
      properties:
        transactionIds: { type: array, items: { type: string } }
      required: [transactionIds]
    JournalLineDto:
      type: object
      properties:
        accountCode: { type: string }
        debitCents: { type: integer, format: int64 }
        creditCents: { type: integer, format: int64 }
        memo: { type: string, nullable: true }
      required: [accountCode, debitCents, creditCents]
    JournalEntryDto:
      type: object
      properties:
        id: { type: string }
        description: { type: string }
        postedAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/JournalLineDto" } }
      required: [id, description, postedAt, lines]
    JournalGenerateResponse:
      type: object
      properties:
        entries: { type: array, items: { $ref: "#/components/schemas/JournalEntryDto" } }
        generated: { type: integer }
        skipped: { type: integer }
      required: [entries, generated, skipped]

    # ── Loyalty & Vouchers ──
    VoucherDto:
      type: object
      properties:
        id: { type: string }
        code: { type: string }
        description: { type: string }
        discountType: { type: string, description: "PERCENT (basis points) | FIXED (cents)" }
        discountValue: { type: integer, format: int64 }
        minSpendCents: { type: integer, format: int64 }
        maxUses: { type: integer, format: int64, nullable: true }
        usesCount: { type: integer, format: int64 }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        active: { type: boolean }
      required: [id, code, description, discountType, discountValue, minSpendCents, usesCount, active]
    VoucherCreateRequest:
      type: object
      properties:
        code: { type: string }
        description: { type: string }
        discountType: { type: string, description: "PERCENT | FIXED" }
        discountValue: { type: integer, format: int64 }
        minSpendCents: { type: integer, format: int64, default: 0 }
        maxUses: { type: integer, format: int64, nullable: true }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        customerId: { type: string, nullable: true }
      required: [code, description, discountType, discountValue]
    VoucherUpdateRequest:
      type: object
      description: "Partial voucher edit (BO-F27). All fields keep-on-absent; `code` is immutable."
      properties:
        description: { type: string, nullable: true }
        discountType: { type: string, nullable: true, description: "PERCENT | FIXED" }
        discountValue: { type: integer, format: int64, nullable: true }
        minSpendCents: { type: integer, format: int64, nullable: true }
        maxUses: { type: integer, format: int64, nullable: true }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        active: { type: boolean, nullable: true }
    VoucherRedeemRequest:
      type: object
      properties:
        code: { type: string }
        transactionId: { type: string }
        byStaffId: { type: string, nullable: true }
      required: [code, transactionId]
    VoucherRedeemResult:
      type: object
      properties:
        voucherId: { type: string }
        discountCents: { type: integer, format: int64 }
      required: [voucherId, discountCents]

    # ── Gift Cards ──
    GiftCardDto:
      type: object
      properties:
        id: { type: string }
        code: { type: string }
        initialCents: { type: integer, format: int64 }
        balanceCents: { type: integer, format: int64 }
        issuedToName: { type: string, nullable: true }
        issuedToEmail: { type: string, nullable: true }
        issuedToPhone: { type: string, nullable: true }
        issuedAt: { type: integer, format: int64 }
        expiresAt: { type: integer, format: int64, nullable: true }
        voided: { type: boolean }
      required: [id, code, initialCents, balanceCents, issuedAt, voided]
    GiftCardIssueRequest:
      type: object
      properties:
        initialCents: { type: integer, format: int64 }
        issuedToName: { type: string, nullable: true }
        issuedToEmail: { type: string, nullable: true }
        issuedToPhone: { type: string, nullable: true }
        expiresAt: { type: integer, format: int64, nullable: true }
        issuedByStaffId: { type: string, nullable: true }
        notes: { type: string, nullable: true }
      required: [initialCents]
    GiftCardRedeemRequest:
      type: object
      properties:
        code: { type: string }
        amountCents: { type: integer, format: int64 }
        transactionId: { type: string }
      required: [code, amountCents, transactionId]
    GiftCardRedeemResult:
      type: object
      properties:
        giftCardId: { type: string }
        redeemedCents: { type: integer, format: int64 }
        remainingCents: { type: integer, format: int64 }
      required: [giftCardId, redeemedCents, remainingCents]
    GiftCardPage:
      type: object
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/GiftCardDto" } }
        total: { type: integer, format: int64 }
        limit: { type: integer }
        offset: { type: integer, format: int64 }
      required: [items, total, limit, offset]

    # ── House Accounts ──
    HouseAccountDto:
      type: object
      properties:
        id: { type: string }
        customerId: { type: string, nullable: true }
        companyName: { type: string, nullable: true }
        contactName: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        creditLimitCents: { type: integer, format: int64 }
        balanceCents: { type: integer, format: int64 }
        status: { type: string }
      required: [id, creditLimitCents, balanceCents, status]
    HouseAccountCreateRequest:
      type: object
      properties:
        customerId: { type: string, nullable: true }
        companyName: { type: string, nullable: true }
        contactName: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        creditLimitCents: { type: integer, format: int64, default: 0 }
    HouseAccountChargeRequest:
      type: object
      properties:
        accountId: { type: string }
        amountCents: { type: integer, format: int64 }
        transactionId: { type: string, nullable: true }
        memo: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [accountId, amountCents]
    HouseAccountSettlementRequest:
      type: object
      properties:
        accountId: { type: string }
        amountCents: { type: integer, format: int64 }
        paymentId: { type: string, nullable: true }
        memo: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [accountId, amountCents]
    HouseAccountPostingDto:
      type: object
      properties:
        id: { type: string }
        accountId: { type: string }
        transactionId: { type: string, nullable: true }
        paymentId: { type: string, nullable: true }
        postingType: { type: string }
        amountCents: { type: integer, format: int64 }
        memo: { type: string, nullable: true }
        postedAt: { type: integer, format: int64 }
      required: [id, accountId, postingType, amountCents, postedAt]

    # ── Inventory ──
    EightySixEntry:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        locationId: { type: string }
        reason: { type: string, nullable: true }
        eightySixedAt: { type: integer, format: int64 }
        byStaffId: { type: string, nullable: true }
      required: [id, productId, locationId, eightySixedAt]
    EightySixRequest:
      type: object
      properties:
        productId: { type: string }
        locationId: { type: string }
        reason: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [productId, locationId]
    ClearEightySixRequest:
      type: object
      properties:
        productId: { type: string }
        locationId: { type: string }
        byStaffId: { type: string, nullable: true }
      required: [productId, locationId]
    WastageRequest:
      type: object
      properties:
        productId: { type: string }
        locationId: { type: string }
        quantityUnits: { type: integer, format: int64 }
        reason: { type: string, nullable: true }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [productId, locationId, quantityUnits]
    LowStockEntry:
      type: object
      properties:
        productId: { type: string }
        onHandUnits: { type: integer, format: int64 }
        reorderPointUnits: { type: integer, format: int64 }
        variantId: { type: string, nullable: true, description: "Set when the threshold is on a variant (V19)." }
      required: [productId, onHandUnits, reorderPointUnits]
    WhereIsLocation:
      type: object
      properties:
        locationId: { type: string }
        locationName: { type: string }
        onHandUnits: { type: integer, format: int64 }
      required: [locationId, locationName, onHandUnits]
    WhereIsEntry:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true, description: "Set when this row is a variant." }
        name: { type: string, description: "Display label, e.g. \"Tee — Large\"." }
        sku: { type: string, nullable: true }
        totalOnHand: { type: integer, format: int64 }
        locations: { type: array, items: { $ref: "#/components/schemas/WhereIsLocation" } }
      required: [productId, name, totalOnHand]
    ReplenishmentEntry:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        name: { type: string }
        onHandUnits: { type: integer, format: int64 }
        soldInWindow: { type: integer, format: int64 }
        perDayRate: { type: number, description: "Observed units sold per day over the window." }
        coverDays:
          type: integer
          format: int64
          nullable: true
          description: >
            Days the shelf lasts at the observed rate. Omitted when nothing sold — undefined,
            not infinite. See deadStock.
        suggestedOrderUnits: { type: integer, format: int64 }
        deadStock: { type: boolean, description: "On the shelf, nothing sold in the window." }
        tiedUpCents: { type: integer, format: int64, nullable: true, description: "Capital on the shelf, when a cost was captured." }
        reorderPointUnits: { type: integer, format: int64, nullable: true }
      required: [productId, name, onHandUnits, soldInWindow, perDayRate, suggestedOrderUnits, deadStock]
    UsageVarianceEntry:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        name: { type: string }
        explainedBySalesUnits: { type: integer, format: int64, description: "Recipe-exploded for composed dishes." }
        loggedWastageUnits: { type: integer, format: int64, description: "A managed cost, not a mystery." }
        unexplainedUnits: { type: integer, format: int64, description: "Positive = the count found less than the books said." }
        unexplainedCents: { type: integer, format: int64, nullable: true }
        unitCostCents: { type: integer, format: int64, nullable: true }
      required: [productId, name]
    RecipeLineUpsert:
      type: object
      properties:
        ingredientProductId: { type: string }
        quantityUnits: { type: integer, format: int64, description: "Quantity for one BATCH, not one portion." }
        unitLabel: { type: string, default: "g", description: "each | g | kg | ml | cl | l | tsp | tbsp (plus ordinary spellings). Must share a dimension with the ingredient's stockUnit; an undeclared ingredient adopts this line's dimension. 400 unknown_unit / unit_mismatch otherwise." }
      required: [ingredientProductId, quantityUnits]
    RecipeUpsertRequest:
      type: object
      properties:
        productId: { type: string, description: "The dish. Also the recipe's identity — one recipe per product." }
        name: { type: string }
        yieldUnits: { type: integer, default: 1, description: "Portions one batch produces. 1 = quantities are already per portion." }
        outputUnits: { type: integer, format: int64, nullable: true, description: "What one batch produces, in this product's own stock unit (V37) — 4000 for a 4 l batch counted in ml. Omit for anything counted in eaches and it equals yieldUnits. This is the denominator another recipe divides by when it uses this product as an ingredient. 400 invalid_output if < 1." }
        wastePctBp: { type: integer, default: 0, description: "Waste allowance in basis points (1000 = 10%). Max 10000." }
        lines: { type: array, items: { $ref: "#/components/schemas/RecipeLineUpsert" } }
      required: [productId, name, lines]
    RecipeLine:
      type: object
      properties:
        ingredientProductId: { type: string }
        ingredientName: { type: string }
        quantityUnits: { type: integer, format: int64, description: "Per batch, as authored — the number typed, in unitLabel." }
        unitLabel: { type: string }
        baseUnits: { type: integer, format: int64, default: 0, description: "quantityUnits converted to the ingredient's stock unit (V36). This is what deducts." }
        stockUnit: { type: string, default: each, enum: [each, g, ml], description: "The unit the ingredient's stock is counted in." }
        perSaleUnits: { type: integer, format: int64, description: "What one sale deducts, after yield and waste, in stockUnit." }
        perSaleDisplay: { type: string, default: "", description: "perSaleUnits the way a person says it — \"105 g\", \"1.5 kg\"." }
        unitsAgree: { type: boolean, default: true, description: "False on a line authored before V36: the label and the stored deduction disagree, and a human must restate it. Nothing corrects it silently." }
        subRecipe: { type: boolean, default: false, description: "True when this ingredient is itself made here and is not counted (V37): it expands into its own ingredients rather than depleting, so nothing on this line moves. lineCostCents is then what the goods inside it cost." }
        unitCostCents: { type: integer, format: int64, nullable: true }
        lineCostCents: { type: integer, format: int64 }
      required: [ingredientProductId, ingredientName, quantityUnits, unitLabel, perSaleUnits]
    Recipe:
      type: object
      properties:
        productId: { type: string }
        productName: { type: string }
        name: { type: string }
        yieldUnits: { type: integer, description: "Portions one batch makes, as authored." }
        outputUnits: { type: integer, format: int64, default: 1, description: "What one batch produces, in this product's own stock unit (V37) — the denominator a parent recipe divides by. Equal to yieldUnits for anything counted in eaches." }
        wastePctBp: { type: integer }
        costCents: { type: integer, format: int64, description: "Derived server-side: ingredient cost of one portion, sub-recipes rolled up." }
        lines: { type: array, items: { $ref: "#/components/schemas/RecipeLine" } }
        consumes:
          type: array
          default: []
          description: "What one portion really takes off the shelf once sub-recipes are followed (V37). Equals the ingredient list for a simple recipe; for a dish built on uncounted prep items these are the raw goods underneath them, which appear nowhere in `lines`."
          items: { $ref: "#/components/schemas/RecipeConsumption" }
      required: [productId, productName, name, yieldUnits, wastePctBp, costCents, lines]
    AccountingConnection:
      type: object
      properties:
        provider: { type: string, enum: [XERO, SAGE, NONE] }
        organisationRef: { type: string, nullable: true, description: "The provider's id for the books — Xero tenant / Sage business. Not a secret." }
        syncFromDate: { type: string, nullable: true, description: "ISO date. Journals before it are never pushed, so a closed period stays closed." }
        active: { type: boolean }
        lastBatchAt: { type: integer, format: int64, nullable: true }
        lastError: { type: string, nullable: true }
        credentialsPresent: { type: boolean, default: false, description: "Whether this server holds credentials for the provider. False with an active connection means the token needs renewing." }
        pending: { type: integer, format: int64, default: 0, description: "Claimed but unresolved — retried next run with the same key." }
        posted: { type: integer, format: int64, default: 0 }
        failed: { type: integer, format: int64, default: 0 }
      required: [provider, active]
    AccountingConnectionRequest:
      type: object
      properties:
        provider: { type: string, enum: [XERO, SAGE, NONE], description: "NONE pauses the push without forgetting the settings." }
        organisationRef: { type: string, nullable: true }
        syncFromDate: { type: string, nullable: true, description: "ISO date (yyyy-MM-dd). 400 invalid_date otherwise." }
        active: { type: boolean, default: true }
      required: [provider]
    AccountingSyncResult:
      type: object
      properties:
        provider: { type: string }
        posted: { type: integer }
        failed: { type: integer, description: "Refused for a reason retrying will not fix." }
        skipped: { type: integer }
        inFlight: { type: integer, description: "Outcome unknown — still claimed, retried next run with the same idempotency key." }
        notConfigured: { type: boolean, default: false }
      required: [provider, posted, failed, skipped, inFlight]
    ServiceCostingLine:
      type: object
      required: [kind, description]
      properties:
        kind: { type: string, enum: [LABOUR, PART] }
        description: { type: string, description: What the customer reads. Required even for a part — products get renamed. }
        productId: { type: string, nullable: true, description: PART only, and required for one — a real catalogue product, so stock moves. }
        variantId: { type: string, nullable: true }
        hoursHundredths: { type: integer, nullable: true, description: LABOUR only. 1.75 h is 175, so no float touches money. }
        rateCentsPerHour: { type: integer, format: int64, nullable: true, description: LABOUR only. }
        quantity: { type: integer, format: int64, nullable: true, description: PART only. }
        unitPriceCents:
          type: integer
          format: int64
          nullable: true
          description: PART — null prices from the catalogue at the job's location and freezes it.
    ServiceCosting:
      type: object
      required: [exists]
      properties:
        id: { type: string, nullable: true }
        exists: { type: boolean, description: false when the job has no sheet yet. }
        serviceOrderId: { type: string, nullable: true }
        actualTotalCents: { type: integer, format: int64, description: Labour plus parts, as the job stands. }
        writeOffCents: { type: integer, format: int64 }
        chargeableCents: { type: integer, format: int64, description: actualTotal minus writeOff — what handover will bill. }
        overrunCents:
          type: integer
          format: int64
          description: How far chargeable exceeds the approved figure. Non-zero blocks handover.
        labourCents: { type: integer, format: int64 }
        partsCents: { type: integer, format: int64 }
        lines:
          type: array
          items: { $ref: "#/components/schemas/ServiceCostingLine" }
    RoomChargeConnection:
      type: object
      required: [connected]
      properties:
        connected: { type: boolean }
        system: { type: string, nullable: true, description: OPERA | MEWS }
        propertyRef: { type: string, nullable: true }
        credentialsPresent:
          type: boolean
          description: Distinguishes "nobody connected it" from "the token expired".
        allowUnverifiedCharge: { type: boolean }
        active: { type: boolean }
    RoomChargeConnectionRequest:
      type: object
      properties:
        system: { type: string, nullable: true, description: OPERA | MEWS | NONE. NONE pauses without forgetting. }
        propertyRef: { type: string, nullable: true }
        allowUnverifiedCharge: { type: boolean }
        active: { type: boolean }
    RoomChargeLookup:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [OK, REFUSED] }
        room: { type: string }
        guestName: { type: string, nullable: true, description: Read this back to the person in front of you. }
        reservationRef: { type: string, nullable: true }
        notConnected: { type: boolean }
        reason: { type: string, nullable: true }
    RoomChargeRequest:
      type: object
      required: [transactionId, room]
      properties:
        transactionId: { type: string }
        room: { type: string }
        reservationRef: { type: string, nullable: true }
        amountCents: { type: integer, format: int64, nullable: true }
    RoomChargePosting:
      type: object
      required: [transactionId, status]
      properties:
        transactionId: { type: string }
        status: { type: string, enum: [PENDING, POSTED, FAILED, REVERSED] }
        alreadyPosted: { type: boolean, description: A repeat collapsed onto the existing claim; nothing was re-sent. }
        room: { type: string, nullable: true }
        guestName: { type: string, nullable: true }
        verified: { type: boolean, description: false when the PMS could not be reached and the property allows it anyway. }
        verifyNote: { type: string, nullable: true }
        providerRef: { type: string, nullable: true }
        idempotencyKey: { type: string }
        attempts: { type: integer }
        lastError: { type: string, nullable: true }
        postedAt: { type: integer, format: int64, nullable: true }
    ProductFeedStore:
      type: object
      required: [storeId, displayName, provider, feedUrl]
      properties:
        storeId: { type: string }
        displayName: { type: string }
        provider: { type: string, description: SHOPIFY | WOOCOMMERCE }
        feedUrl:
          type: string
          description: >-
            Paste into Merchant Center or Commerce Manager. Non-expiring by design — an expiring
            feed URL takes a shop's Shopping presence dark on a date nobody wrote down.
    ShippingRateBand:
      type: object
      required: [priceCents]
      properties:
        zone: { type: string, nullable: true, description: null applies to every zone. }
        upToG:
          type: integer
          nullable: true
          description: Inclusive gram ceiling. null is the open top ("and above").
        priceCents: { type: integer, format: int64, minimum: 0 }
    ShippingMethod:
      type: object
      required: [id, name, kind, flatCents, active, sortOrder]
      properties:
        id: { type: string }
        name: { type: string }
        kind: { type: string, enum: [FLAT, WEIGHT, ZONE] }
        flatCents: { type: integer, format: int64 }
        freeOverCents:
          type: integer
          format: int64
          nullable: true
          description: Applies only after a band matched, so it never rescues an undeliverable address.
        leadTimeNote: { type: string, nullable: true }
        active: { type: boolean }
        sortOrder: { type: integer }
        bands:
          type: array
          items: { $ref: "#/components/schemas/ShippingRateBand" }
    ShippingMethodRequest:
      type: object
      required: [name, kind]
      properties:
        id: { type: string, nullable: true }
        name: { type: string }
        kind: { type: string, enum: [FLAT, WEIGHT, ZONE] }
        flatCents: { type: integer, format: int64, default: 0 }
        freeOverCents: { type: integer, format: int64, nullable: true }
        leadTimeNote: { type: string, nullable: true }
        active: { type: boolean, default: true }
        sortOrder: { type: integer, default: 0 }
        bands:
          type: array
          description: Replaces every band on the method.
          items: { $ref: "#/components/schemas/ShippingRateBand" }
    ShippingZone:
      type: object
      required: [zone, postcodePrefix]
      properties:
        zone: { type: string }
        postcodePrefix: { type: string, description: The longest matching prefix wins. }
    ShippingQuoteRequest:
      type: object
      properties:
        postcode: { type: string, nullable: true }
        weightG: { type: integer, nullable: true, description: A weighed parcel. Wins over lines. }
        lines:
          type: array
          nullable: true
          description: The basket, so the server weighs it from the catalogue.
          items:
            type: object
            required: [productId, quantity]
            properties:
              productId: { type: string }
              quantity: { type: integer, format: int64 }
        subtotalCents: { type: integer, format: int64, default: 0 }
    ShippingQuoteOption:
      type: object
      required: [methodId, methodName, available]
      properties:
        methodId: { type: string }
        methodName: { type: string }
        priceCents: { type: integer, format: int64, nullable: true }
        free: { type: boolean }
        available: { type: boolean }
        reason:
          type: string
          nullable: true
          enum: [needs_weight, no_band, zone_not_served]
    ShippingQuoteResponse:
      type: object
      required: [options]
      properties:
        zone: { type: string, nullable: true }
        weightG: { type: integer, nullable: true }
        options:
          type: array
          items: { $ref: "#/components/schemas/ShippingQuoteOption" }
    ShipmentRequest:
      type: object
      required: [recipientName, line1, city, postcode]
      properties:
        locationId: { type: string, nullable: true }
        transactionId: { type: string, nullable: true }
        orderRef: { type: string, nullable: true }
        methodId: { type: string, nullable: true }
        recipientName: { type: string }
        phone: { type: string, nullable: true }
        line1: { type: string }
        line2: { type: string, nullable: true }
        city: { type: string }
        postcode: { type: string }
        country: { type: string, nullable: true, default: ZA }
        weightG: { type: integer, nullable: true }
        priceCents:
          type: integer
          format: int64
          nullable: true
          description: What the customer was charged. Omit to derive from the method.
        notes: { type: string, nullable: true }
    Shipment:
      type: object
      required: [id, waybillNo, methodName, recipientName, line1, city, postcode, country, priceCents, status, createdAt]
      properties:
        id: { type: string }
        waybillNo: { type: string, description: 'WB-yyyyMMdd-NNNNNN, unique per tenant.' }
        transactionId: { type: string, nullable: true }
        orderRef: { type: string, nullable: true }
        methodName: { type: string }
        trackingRef: { type: string, nullable: true }
        recipientName: { type: string }
        phone: { type: string, nullable: true }
        line1: { type: string }
        line2: { type: string, nullable: true }
        city: { type: string }
        postcode: { type: string }
        country: { type: string }
        zone: { type: string, nullable: true }
        weightG: { type: integer, nullable: true, description: null means unweighed, not weightless. }
        priceCents: { type: integer, format: int64 }
        status: { type: string, enum: [PENDING, PACKED, SHIPPED, DELIVERED, CANCELLED] }
        notes: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
    AccountingPosting:
      type: object
      properties:
        entryId: { type: string }
        provider: { type: string }
        status: { type: string, enum: [PENDING, POSTED, FAILED, SKIPPED] }
        providerRef: { type: string, nullable: true, description: "Their id for the document — the audit trail from a line here to a line in their ledger." }
        attempts: { type: integer, default: 0 }
        lastError: { type: string, nullable: true }
        postedAt: { type: integer, format: int64, nullable: true }
      required: [entryId, provider, status]
    RecipeConsumption:
      type: object
      properties:
        productId: { type: string }
        productName: { type: string }
        units: { type: integer, format: int64, description: "In the product's stock unit." }
        stockUnit: { type: string, enum: [each, g, ml] }
        display: { type: string, description: "The way a person says it — \"105 g\"." }
        costCents: { type: integer, format: int64, default: 0 }
      required: [productId, productName, units, stockUnit, display]
    AdjustmentRequest:
      type: object
      properties:
        productId: { type: string }
        locationId: { type: string }
        deltaUnits: { type: integer, format: int64 }
        reason: { type: string }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [productId, locationId, deltaUnits, reason]

    # ── Suppliers & Purchasing ──
    SupplierDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        contactName: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        vatNumber: { type: string, nullable: true }
        paymentTermsDays: { type: integer }
        active: { type: boolean }
      required: [id, name, paymentTermsDays, active]
    SupplierCreateRequest:
      type: object
      properties:
        name: { type: string }
        contactName: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        vatNumber: { type: string, nullable: true }
        paymentTermsDays: { type: integer, default: 30 }
      required: [name]
    SupplierUpdateRequest:
      type: object
      description: "Partial supplier update incl. `active` (BO-F32). All fields keep-on-absent."
      properties:
        name: { type: string, nullable: true }
        contactName: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        vatNumber: { type: string, nullable: true }
        paymentTermsDays: { type: integer, nullable: true }
        notes: { type: string, nullable: true }
        active: { type: boolean, nullable: true }
    PoLineRequest:
      type: object
      properties:
        productId: { type: string }
        quantityUnits: { type: integer, format: int64 }
        unitCostCents: { type: integer, format: int64 }
      required: [productId, quantityUnits, unitCostCents]
    PoCreateRequest:
      type: object
      properties:
        supplierId: { type: string }
        locationId: { type: string }
        expectedAt: { type: integer, format: int64, nullable: true }
        notes: { type: string, nullable: true }
        lines: { type: array, items: { $ref: "#/components/schemas/PoLineRequest" } }
        byStaffId: { type: string, nullable: true }
      required: [supplierId, locationId, lines]
    PurchaseOrderDto:
      type: object
      properties:
        id: { type: string }
        poNumber: { type: string }
        supplierId: { type: string }
        locationId: { type: string }
        status: { type: string }
        expectedAt: { type: integer, format: int64, nullable: true }
        receivedAt: { type: integer, format: int64, nullable: true }
        totalCents: { type: integer, format: int64 }
      required: [id, poNumber, supplierId, locationId, status, totalCents]
    PoLineDto:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        quantityUnits: { type: integer, format: int64 }
        receivedUnits: { type: integer, format: int64 }
        unitCostCents: { type: integer, format: int64 }
        lineTotalCents: { type: integer, format: int64 }
        sortOrder: { type: integer }
      required: [id, productId, quantityUnits, receivedUnits, unitCostCents, lineTotalCents, sortOrder]
    PurchaseOrderDetailDto:
      type: object
      description: Full PO detail (V16) — header with resolved supplierName plus lines with resolved productName.
      properties:
        id: { type: string }
        poNumber: { type: string }
        supplierId: { type: string }
        supplierName: { type: string, description: "Resolved supplier name (falls back to supplierId if the supplier row is gone)." }
        status: { type: string }
        expectedAt: { type: integer, format: int64, nullable: true }
        notes: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/PoDetailLineDto" } }
      required: [id, poNumber, supplierId, supplierName, status, createdAt, totalCents, lines]
    PoDetailLineDto:
      type: object
      properties:
        productId: { type: string }
        productName: { type: string, description: "Resolved product name (falls back to productId if the product row is gone)." }
        qty: { type: integer, format: int64 }
        unitCostCents: { type: integer, format: int64 }
        lineTotalCents: { type: integer, format: int64 }
      required: [productId, productName, qty, unitCostCents, lineTotalCents]
    PoReceiveLine:
      type: object
      properties:
        lineId: { type: string }
        receivedQty: { type: integer, format: int64 }
      required: [lineId, receivedQty]
    PoReceiveRequest:
      type: object
      properties:
        lines: { type: array, items: { $ref: "#/components/schemas/PoReceiveLine" }, nullable: true, description: "Omit (or send no body) to receive every outstanding unit." }

    # ── Pricing ──
    PriceRuleDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        scopeType: { type: string, description: "PRODUCT | CATEGORY | ALL" }
        scopeId: { type: string, nullable: true }
        ruleType: { type: string, description: "PERCENT_OFF | FIXED_OFF | OVERRIDE_PRICE" }
        value: { type: integer, format: int64 }
        daysOfWeek: { type: string }
        startMinute: { type: integer }
        endMinute: { type: integer }
        priority: { type: integer }
        active: { type: boolean }
      required: [id, name, scopeType, ruleType, value, daysOfWeek, startMinute, endMinute, priority, active]
    PriceRuleCreateRequest:
      type: object
      properties:
        name: { type: string }
        scopeType: { type: string, description: "PRODUCT | CATEGORY | ALL" }
        scopeId: { type: string, nullable: true }
        ruleType: { type: string, description: "PERCENT_OFF | FIXED_OFF | OVERRIDE_PRICE" }
        value: { type: integer, format: int64 }
        daysOfWeek: { type: string, default: MTWRFSU }
        startMinute: { type: integer, default: 0 }
        endMinute: { type: integer, default: 1439 }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        priority: { type: integer, default: 0 }
      required: [name, scopeType, ruleType, value]

    # ── Customer QR ──
    QrCategoryDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        courseType: { type: string }
        colorHex: { type: string }
        sortOrder: { type: integer }
      required: [id, name, courseType, colorHex, sortOrder]
    QrModifierOptionDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        priceDeltaCents: { type: integer, format: int64 }
      required: [id, name, priceDeltaCents]
    QrModifierGroupDto:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        required: { type: boolean }
        multiSelect: { type: boolean }
        minSelect: { type: integer }
        maxSelect: { type: integer }
        options: { type: array, items: { $ref: "#/components/schemas/QrModifierOptionDto" } }
      required: [id, name, required, multiSelect, minSelect, maxSelect, options]
    QrProductDto:
      type: object
      properties:
        id: { type: string }
        categoryId: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        emoji: { type: string, nullable: true }
        imageUrl: { type: string, nullable: true }
        priceCents: { type: integer, format: int64 }
        vatType: { type: string }
        allergens: { type: array, items: { type: string }, default: [], description: "Allergen names from the product record." }
        modifierGroups: { type: array, items: { $ref: "#/components/schemas/QrModifierGroupDto" }, default: [] }
      required: [id, categoryId, name, priceCents, vatType]
    QrFeaturesDto:
      type: object
      description: |
        What this venue's QR licence and payment rails let a guest do (V72). The guest UI renders from
        this rather than guessing.
      properties:
        cardCheckout: { type: boolean, default: false, description: "Yoco hosted checkout — 'Pay now' hands back a checkout link." }
        snapscan: { type: boolean, default: false, description: "Static SnapScan QR as a payment surface." }
        payOnline: { type: boolean, default: false, description: "Any online rail at all — false means every order is pay-at-counter." }
        addToTab: { type: boolean, default: false, description: "'Add to my table's bill' — true only for Restaurant-licensed venues." }
    QrMenuResponse:
      type: object
      properties:
        locationId: { type: string }
        locationName: { type: string }
        categories: { type: array, items: { $ref: "#/components/schemas/QrCategoryDto" } }
        products: { type: array, items: { $ref: "#/components/schemas/QrProductDto" } }
        features: { $ref: "#/components/schemas/QrFeaturesDto" }
      required: [locationId, locationName, categories, products]
    QrOrderLine:
      type: object
      properties:
        productId: { type: string }
        quantity: { type: integer }
        note: { type: string, nullable: true }
        modifierOptionIds: { type: array, items: { type: string }, default: [] }
      required: [productId, quantity]
    QrOrderRequest:
      type: object
      properties:
        locationId: { type: string }
        tableLabel: { type: string, nullable: true, description: "Table label from the scanned QR code, if any." }
        lines: { type: array, items: { $ref: "#/components/schemas/QrOrderLine" } }
        notes: { type: string, nullable: true }
        tipCents: { type: integer, format: int64, default: 0, description: "Optional guest tip in cents (>= 0); persisted on the order and added to the SnapScan payment amount. Max R10,000." }
        fulfilmentType: { type: string, default: "DINE_IN", enum: [DINE_IN, TAKEAWAY], description: "DINE_IN needs a table; TAKEAWAY (click-and-collect) makes the table optional." }
        scheduledForMillis: { type: integer, format: int64, nullable: true, description: "Order-for-later pickup time (epoch millis). Must be in the future and within 14 days. Null = as-soon-as-ready." }
        pickupName: { type: string, nullable: true, description: "Collector name for a takeaway order." }
        pickupPhone: { type: string, nullable: true, description: "Collector phone for a takeaway order." }
        paymentMode: { type: string, default: "PAY_NOW", enum: [PAY_NOW, ADD_TO_TAB], description: "PAY_NOW settles online immediately; ADD_TO_TAB joins the table's running bill (dine-in, Restaurant-licensed venues only — V72)." }
      required: [locationId, lines]
    QrOrderResponse:
      type: object
      properties:
        orderId: { type: string, description: "The order to poll. For ADD_TO_TAB this is the TABLE's bill, not a new order." }
        transactionNumber: { type: string }
        subtotalCents: { type: integer, format: int64, description: "This guest's own addition, not the whole bill." }
        vatCents: { type: integer, format: int64 }
        tipCents: { type: integer, format: int64, default: 0 }
        totalCents: { type: integer, format: int64, description: "subtotal + tip (the guest's own charge)." }
        paymentMode: { type: string, default: "PAY_NOW", description: "PAY_NOW | ADD_TO_TAB, echoed back." }
        settlement: { type: string, default: "ONLINE", description: "ONLINE — pay from the phone now; TABLE_BILL — the till settles it later." }
        checkoutUrl: { type: string, nullable: true, description: "Yoco hosted checkout to redirect the guest to (PAY_NOW with Yoco configured)." }
        billTotalCents: { type: integer, format: int64, nullable: true, description: "The whole table bill after an ADD_TO_TAB append." }
        pollable: { type: boolean, default: true, description: "False when the joined bill is till-authored — its status is not the guest's to poll." }
      required: [orderId, transactionNumber, subtotalCents, vatCents, totalCents]
    QrOrderStatusResponse:
      type: object
      properties:
        id: { type: string }
        status: { type: string }
        kitchenStatus: { type: string, description: "RECEIVED | PREPARING | READY" }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        placedAt: { type: integer, format: int64 }
      required: [id, status, kitchenStatus, totalCents, paidCents, placedAt]
    QrPaymentResponse:
      type: object
      properties:
        mode: { type: string, description: "YOCO (hosted checkout link) | SNAPSCAN | PAY_AT_COUNTER | PAID" }
        url: { type: string, nullable: true, description: "The checkout/payment URL for YOCO and SNAPSCAN modes." }
        qrPngBase64: { type: string, nullable: true, description: "Base64-encoded PNG of `url`, for direct <img>/ImageBitmap use. Present for SNAPSCAN and for YOCO (so a shared kiosk screen can hand the payment to the guest's own phone)." }
        status: { type: string, nullable: true, description: "PENDING | PAID" }
        amountCents: { type: integer, format: int64, nullable: true }
      required: [mode]

    # ── Booking ──
    BookingSlotDto:
      type: object
      properties:
        atMillis: { type: integer, format: int64, description: "Slot start (epoch millis, Africa/Johannesburg wall-clock)." }
        label: { type: string, description: "\"HH:mm\" local display label." }
        available: { type: boolean }
        remainingSeats: { type: integer, nullable: true, description: "Seats left in the window; null when the location has no tables configured." }
      required: [atMillis, label, available]
    BookingAvailabilityResponse:
      type: object
      properties:
        locationId: { type: string }
        locationName: { type: string }
        date: { type: string, description: "yyyy-MM-dd echoed back." }
        partySize: { type: integer }
        slots: { type: array, items: { $ref: "#/components/schemas/BookingSlotDto" } }
      required: [locationId, locationName, date, partySize, slots]
    BookingReservationRequest:
      type: object
      properties:
        locationId: { type: string }
        guestName: { type: string }
        partySize: { type: integer, description: "1..20." }
        reservedForAt: { type: integer, format: int64, description: "Requested time (epoch millis) — use an availability slot's atMillis." }
        durationMinutes: { type: integer, default: 90 }
        phone: { type: string, nullable: true, description: "Required unless email is given." }
        email: { type: string, nullable: true, description: "Required unless phone is given." }
        notes: { type: string, nullable: true }
      required: [locationId, guestName, partySize, reservedForAt]
    BookingReservationResponse:
      type: object
      properties:
        id: { type: string }
        status: { type: string, description: "Always PENDING on create." }
        reservedForAt: { type: integer, format: int64 }
        durationMinutes: { type: integer }
        partySize: { type: integer }
        guestName: { type: string }
      required: [id, status, reservedForAt, durationMinutes, partySize, guestName]

    # ── Checklists ──
    IdResponse:
      type: object
      properties:
        id: { type: string }
      required: [id]
    ChecklistItemDto:
      type: object
      properties:
        id: { type: string }
        label: { type: string }
        sortOrder: { type: integer }
      required: [id, label, sortOrder]
    ChecklistDto:
      type: object
      properties:
        id: { type: string }
        locationId: { type: string, nullable: true, description: "Null = applies to every location." }
        title: { type: string }
        recurrence: { type: string, description: "DAILY | WEEKLY | NONE" }
        active: { type: boolean }
        items: { type: array, items: { $ref: "#/components/schemas/ChecklistItemDto" } }
      required: [id, title, recurrence, active, items]
    ChecklistItemUpsert:
      type: object
      properties:
        id: { type: string, nullable: true, description: "Present = update that item; absent = insert a new item." }
        label: { type: string }
        sortOrder: { type: integer, default: 0 }
      required: [label]
    ChecklistUpsertRequest:
      type: object
      properties:
        locationId: { type: string, nullable: true }
        title: { type: string }
        recurrence: { type: string, default: "DAILY", enum: [DAILY, WEEKLY, NONE] }
        active: { type: boolean, default: true }
        items: { type: array, items: { $ref: "#/components/schemas/ChecklistItemUpsert" } }
      required: [title]
    ChecklistRunItemDto:
      type: object
      properties:
        itemId: { type: string }
        label: { type: string }
        sortOrder: { type: integer }
        done: { type: boolean }
        doneByStaffId: { type: string, nullable: true }
        doneAt: { type: integer, format: int64, nullable: true }
        note: { type: string, nullable: true }
      required: [itemId, label, sortOrder, done]
    ChecklistRunDto:
      type: object
      properties:
        id: { type: string }
        checklistId: { type: string }
        title: { type: string }
        businessDate: { type: string, description: "yyyy-MM-dd (Africa/Johannesburg)." }
        status: { type: string, description: "OPEN | COMPLETE" }
        doneCount: { type: integer }
        totalCount: { type: integer }
        items: { type: array, items: { $ref: "#/components/schemas/ChecklistRunItemDto" } }
      required: [id, checklistId, title, businessDate, status, doneCount, totalCount, items]
    ChecklistItemMarkRequest:
      type: object
      properties:
        done: { type: boolean }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true, description: "Optional staff id (validated) for shared terminals; defaults to the caller." }
      required: [done]
    ChecklistItemMarkResponse:
      type: object
      properties:
        runId: { type: string }
        itemId: { type: string }
        done: { type: boolean }
        runStatus: { type: string, description: "OPEN | COMPLETE" }
        doneCount: { type: integer }
        totalCount: { type: integer }
      required: [runId, itemId, done, runStatus, doneCount, totalCount]
    ChecklistSummarySendResponse:
      type: object
      properties:
        businessDate: { type: string, description: "yyyy-MM-dd (Africa/Johannesburg)." }
        locationId: { type: string, nullable: true }
        recipients: { type: array, items: { type: string }, description: "Resolved from CHECKLIST_SUMMARY_TO (comma-separated)." }
        checklistsCompleted: { type: integer }
        checklistsOpen: { type: integer }
        totalItems: { type: integer }
        doneItems: { type: integer }
        subject: { type: string }
        status: { type: string, description: "sent | email_not_configured | no_recipient | send_failed" }
        sent: { type: boolean }
        textBody: { type: string, description: "Rendered plain-text summary (echoed for dev verification)." }
        htmlBody: { type: string, nullable: true, description: "Rendered HTML summary." }
      required: [businessDate, recipients, checklistsCompleted, checklistsOpen, totalItems, doneItems, subject, status, sent, textBody]

    # ── Stocktake sessions (V20) ──
    OpenCountRequest:
      type: object
      properties:
        locationId: { type: string }
        scopeType: { type: string, enum: [ALL, CATEGORY, SUPPLIER, PRODUCTS], default: ALL }
        scopeIds: { type: array, nullable: true, items: { type: string }, description: "Required unless scopeType is ALL." }
        blind: { type: boolean, default: false, description: "Hide expected quantities from the counter until review." }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [locationId]
    OpenCountResponse:
      type: object
      properties:
        id: { type: string }
        reference: { type: string, description: "e.g. `SC-20260804-000001`." }
        lineCount: { type: integer }
        blind: { type: boolean }
      required: [id, reference, lineCount, blind]
    SubmitCountRequest:
      type: object
      properties:
        counts: { type: array, items: { $ref: "#/components/schemas/SubmitCountLine" } }
        byStaffId: { type: string, nullable: true }
      required: [counts]
    SubmitCountLine:
      type: object
      properties:
        productId: { type: string }
        countedUnits: { type: integer, format: int64, description: "0 is a real count and posts a variance." }
        variantId: { type: string, nullable: true }
        note: { type: string, nullable: true }
      required: [productId, countedUnits]
    StockCountSessionLine:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        variantId: { type: string, nullable: true }
        sku: { type: string, nullable: true }
        expectedUnits: { type: integer, format: int64, nullable: true, description: "Absent on a blind session that is still COUNTING." }
        countedUnits: { type: integer, format: int64, nullable: true, description: "Absent means not yet counted — distinct from a counted 0." }
        varianceUnits: { type: integer, format: int64, nullable: true, description: "Absent while expectedUnits is withheld." }
        countedByStaffId: { type: string, nullable: true }
        countedAt: { type: integer, format: int64, nullable: true }
        note: { type: string, nullable: true }
      required: [productId, name]
    StockCountSession:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        locationId: { type: string }
        status: { type: string, enum: [DRAFT, COUNTING, REVIEW, APPROVED, CANCELLED] }
        scopeType: { type: string }
        blind: { type: boolean }
        note: { type: string, nullable: true }
        createdByStaffId: { type: string, nullable: true }
        approvedByStaffId: { type: string, nullable: true }
        approvedAt: { type: integer, format: int64, nullable: true }
        createdAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/StockCountSessionLine" } }
        countedLines: { type: integer }
        totalLines: { type: integer }
      required: [id, reference, locationId, status, scopeType, blind, createdAt, lines, countedLines, totalLines]
    StockCountSummary:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        locationId: { type: string }
        status: { type: string }
        scopeType: { type: string }
        blind: { type: boolean }
        totalLines: { type: integer }
        countedLines: { type: integer }
        totalVarianceUnits: { type: integer, format: int64, nullable: true, description: "Withheld while a blind count is open." }
        createdAt: { type: integer, format: int64 }
        approvedAt: { type: integer, format: int64, nullable: true }
      required: [id, reference, locationId, status, scopeType, blind, totalLines, countedLines, createdAt]
    ApproveCountResponse:
      type: object
      properties:
        applied: { type: integer, description: "Lines that produced a movement." }
        totalVarianceUnits: { type: integer, format: int64, description: "Sum of ABSOLUTE variances." }
        varianceValueCents: { type: integer, format: int64, description: "Signed: negative wrote stock off, positive found it." }
        eightySixed: { type: array, items: { type: string } }
      required: [applied, totalVarianceUnits, varianceValueCents]

    # ── Valuation (V21) ──
    CategoryValuation:
      type: object
      properties:
        categoryId: { type: string, nullable: true }
        categoryName: { type: string }
        units: { type: integer, format: int64 }
        valueCents: { type: integer, format: int64 }
        skuCount: { type: integer }
      required: [categoryName, units, valueCents, skuCount]
    ValuationReport:
      type: object
      properties:
        locationId: { type: string }
        asOf: { type: integer, format: int64 }
        totalValueCents: { type: integer, format: int64, description: "Tracked cost of the stock actually held." }
        totalUnits: { type: integer, format: int64 }
        skuCount: { type: integer }
        listValueCents: { type: integer, format: int64, description: "Same stock at today's list cost — a wide gap means stale list costs." }
        byCategory: { type: array, items: { $ref: "#/components/schemas/CategoryValuation" } }
      required: [locationId, asOf, totalValueCents, totalUnits, skuCount, listValueCents, byCategory]
    ValuationPoint:
      type: object
      properties:
        date: { type: string, description: "Trading day in SAST, `yyyy-MM-dd`." }
        valueCents: { type: integer, format: int64 }
        units: { type: integer, format: int64 }
      required: [date, valueCents, units]
    ValuationHistory:
      type: object
      properties:
        locationId: { type: string }
        days: { type: integer }
        coveredFrom: { type: integer, format: int64, nullable: true, description: "Earliest movement in the window; before it the series is flat." }
        points: { type: array, items: { $ref: "#/components/schemas/ValuationPoint" } }
      required: [locationId, days, points]
    CogsReport:
      type: object
      properties:
        locationId: { type: string }
        days: { type: integer }
        cogsCents: { type: integer, format: int64, description: "SALE movements only." }
        unitsSold: { type: integer, format: int64 }
        wastageCents: { type: integer, format: int64, description: "Waste, spoilage and expiry — money gone." }
        returnedToSupplierCents: { type: integer, format: int64, description: "RTV — money owed, not lost." }
        averageUnitCostCents: { type: integer, format: int64, nullable: true, description: "Null when nothing sold in the window." }
      required: [locationId, days, cogsCents, unitsSold, wastageCents, returnedToSupplierCents]

    # ── Batches / lots (V23) ──
    BatchEntry:
      type: object
      properties:
        id: { type: string }
        batchCode: { type: string }
        productId: { type: string }
        name: { type: string }
        variantId: { type: string, nullable: true }
        locationId: { type: string }
        expiresAt: { type: integer, format: int64, nullable: true, description: "Null = no expiry; sorts last in FEFO." }
        daysToExpiry: { type: integer, nullable: true, description: "Negative for a lot already past its date." }
        expired: { type: boolean }
        receivedAt: { type: integer, format: int64 }
        receivedUnits: { type: integer, format: int64 }
        remainingUnits: { type: integer, format: int64 }
        unitCostCents: { type: integer, format: int64 }
        supplierId: { type: string, nullable: true }
        poId: { type: string, nullable: true }
      required: [id, batchCode, productId, name, locationId, receivedAt, receivedUnits, remainingUnits, unitCostCents]
    ExpiringReport:
      type: object
      properties:
        windowDays: { type: integer }
        expired: { type: array, items: { $ref: "#/components/schemas/BatchEntry" } }
        expiringSoon: { type: array, items: { $ref: "#/components/schemas/BatchEntry" } }
        valueAtRiskCents: { type: integer, format: int64 }
      required: [windowDays, expired, expiringSoon, valueAtRiskCents]
    BatchTraceLocation:
      type: object
      properties:
        locationId: { type: string }
        locationName: { type: string, nullable: true }
        remainingUnits: { type: integer, format: int64 }
      required: [locationId, remainingUnits]
    BatchTrace:
      type: object
      properties:
        batchCode: { type: string }
        receivedUnits: { type: integer, format: int64 }
        remainingUnits: { type: integer, format: int64 }
        locations: { type: array, items: { $ref: "#/components/schemas/BatchTraceLocation" } }
        batches: { type: array, items: { $ref: "#/components/schemas/BatchEntry" } }
      required: [batchCode, receivedUnits, remainingUnits, locations, batches]
    BatchDrift:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        variantId: { type: string, nullable: true }
        onHandUnits: { type: integer, format: int64 }
        lotUnits: { type: integer, format: int64 }
        driftUnits: { type: integer, format: int64, description: "on-hand minus live lot total." }
      required: [productId, name, onHandUnits, lotUnits, driftUnits]
    CreateBatchRequest:
      type: object
      properties:
        locationId: { type: string }
        productId: { type: string }
        batchCode: { type: string }
        quantityUnits: { type: integer, format: int64 }
        unitCostCents: { type: integer, format: int64 }
        variantId: { type: string, nullable: true }
        expiresAt: { type: integer, format: int64, nullable: true, description: "Omit to derive from the product's shelfLifeDays." }
        supplierId: { type: string, nullable: true }
        reason: { type: string, nullable: true, default: BATCH_IN }
        note: { type: string, nullable: true }
      required: [locationId, productId, batchCode, quantityUnits, unitCostCents]
    CreateBatchResponse:
      type: object
      properties:
        batchId: { type: string }
        batchCode: { type: string }
        onHandAfter: { type: integer, format: int64 }
        valueAfterCents: { type: integer, format: int64 }
      required: [batchId, batchCode, onHandAfter, valueAfterCents]
    WriteOffBatchRequest:
      type: object
      properties:
        batchIds: { type: array, items: { type: string } }
        note: { type: string, nullable: true }
      required: [batchIds]
    WriteOffBatchResponse:
      type: object
      properties:
        lotsWrittenOff: { type: integer }
        unitsWrittenOff: { type: integer, format: int64 }
        valueWrittenOffCents: { type: integer, format: int64 }
        eightySixed: { type: array, items: { type: string } }
      required: [lotsWrittenOff, unitsWrittenOff, valueWrittenOffCents]

    # ── Supplier returns / RTV (V22) ──
    CreateReturnRequest:
      type: object
      properties:
        locationId: { type: string }
        supplierId: { type: string }
        lines: { type: array, items: { $ref: "#/components/schemas/CreateReturnLine" } }
        reason: { type: string, enum: [DAMAGED, FAULTY, EXPIRED, OVER_SUPPLIED, RECALL, OTHER], default: OTHER }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
      required: [locationId, supplierId, lines]
    CreateReturnLine:
      type: object
      properties:
        productId: { type: string }
        quantityUnits: { type: integer, format: int64 }
        variantId: { type: string, nullable: true }
        reason: { type: string, nullable: true, description: "Overrides the header reason for a mixed return." }
        note: { type: string, nullable: true }
      required: [productId, quantityUnits]
    CreditReturnRequest:
      type: object
      properties:
        creditCents: { type: integer, format: int64, description: "May be less than the claim; 0 records a refused claim." }
        creditNoteRef: { type: string, nullable: true }
      required: [creditCents]
    SendReturnResponse:
      type: object
      properties:
        id: { type: string }
        status: { type: string }
        totalCents: { type: integer, format: int64 }
        eightySixed: { type: array, items: { type: string } }
      required: [id, status, totalCents]
    SupplierReturnLine:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        name: { type: string }
        variantId: { type: string, nullable: true }
        quantityUnits: { type: integer, format: int64 }
        unitCostCents: { type: integer, format: int64, description: "Frozen when sent — a later price change cannot restate the claim." }
        lineTotalCents: { type: integer, format: int64 }
        reason: { type: string, nullable: true }
        note: { type: string, nullable: true }
      required: [id, productId, name, quantityUnits, unitCostCents, lineTotalCents]
    SupplierReturn:
      type: object
      properties:
        id: { type: string }
        reference: { type: string, description: "e.g. `RTV-20260804-000001`." }
        locationId: { type: string }
        supplierId: { type: string }
        supplierName: { type: string, nullable: true }
        status: { type: string, enum: [DRAFT, SENT, CREDITED, CANCELLED] }
        reason: { type: string }
        note: { type: string, nullable: true }
        totalCents: { type: integer, format: int64, description: "0 until sent — lines are priced on send." }
        creditCents: { type: integer, format: int64, nullable: true }
        shortfallCents: { type: integer, format: int64, nullable: true, description: "Claimed minus credited; null until a credit is recorded." }
        creditNoteRef: { type: string, nullable: true }
        sentAt: { type: integer, format: int64, nullable: true }
        creditedAt: { type: integer, format: int64, nullable: true }
        createdAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/SupplierReturnLine" } }
      required: [id, reference, locationId, supplierId, status, reason, totalCents, createdAt]

    # ── Loyalty points (V24) ──
    LoyaltySettingsRequest:
      type: object
      description: "Keep-on-absent — omitted fields keep their current value."
      properties:
        pointsPerRandBp: { type: integer, nullable: true, description: "Basis points of a point per rand earned; 1000 = 0.1 pts/rand." }
        centsPerPoint: { type: integer, format: int64, nullable: true, description: "What one point is worth when spent; 100 = R1.00. Must be >= 1." }
        minRedeemPoints: { type: integer, format: int64, nullable: true }
        maxRedeemPctBp: { type: integer, nullable: true, description: "Cap on redemption as a share of the bill; 5000 = 50%, 10000 = settle in full." }
        redemptionActive: { type: boolean, nullable: true, description: "False refuses redemption but leaves earning running." }
    LoyaltySettings:
      type: object
      properties:
        pointsPerRandBp: { type: integer }
        centsPerPoint: { type: integer, format: int64 }
        minRedeemPoints: { type: integer, format: int64 }
        maxRedeemPctBp: { type: integer }
        redemptionActive: { type: boolean }
      required: [pointsPerRandBp, centsPerPoint, minRedeemPoints, maxRedeemPctBp, redemptionActive]
    LoyaltyLedgerEntry:
      type: object
      properties:
        id: { type: string }
        deltaPoints: { type: integer, format: int64, description: "Negative on a REDEEM." }
        reason: { type: string, description: "EARN | REDEEM | REDEEM_REVERSED | manual adjustment." }
        referenceId: { type: string, nullable: true, description: "The order, where one applies." }
        occurredAt: { type: integer, format: int64 }
      required: [id, deltaPoints, reason, occurredAt]
    LoyaltyBalance:
      type: object
      properties:
        customerId: { type: string }
        name: { type: string }
        points: { type: integer, format: int64 }
        valueCents: { type: integer, format: int64, description: "points x centsPerPoint." }
        tier: { type: string }
        recent: { type: array, items: { $ref: "#/components/schemas/LoyaltyLedgerEntry" } }
      required: [customerId, name, points, valueCents, tier]
    LoyaltyQuoteRequest:
      type: object
      properties:
        customerId: { type: string }
        orderId: { type: string }
      required: [customerId, orderId]
    LoyaltyQuote:
      type: object
      properties:
        orderId: { type: string }
        customerId: { type: string }
        balancePoints: { type: integer, format: int64 }
        balanceValueCents: { type: integer, format: int64, description: "What the WHOLE balance is worth — reported even when redemption is off." }
        maxPoints: { type: integer, format: int64, description: "The largest redemption allowed on this bill." }
        maxValueCents: { type: integer, format: int64 }
        outstandingCents: { type: integer, format: int64, description: "Subtotal less discounts already applied." }
        centsPerPoint: { type: integer, format: int64 }
        available: { type: boolean }
        cappedByBill: { type: boolean, description: "True when the guest could have spent more if the bill allowed." }
        reason: { type: string, enum: [OK, DISABLED, BELOW_MINIMUM, NOTHING_TO_DISCOUNT, NO_BALANCE] }
      required: [orderId, customerId, balancePoints, balanceValueCents, maxPoints, maxValueCents, outstandingCents, centsPerPoint, available, cappedByBill, reason]
    LoyaltyRedeemRequest:
      type: object
      properties:
        customerId: { type: string }
        orderId: { type: string }
        points: { type: integer, format: int64, description: "Must be <= the quote's maxPoints; more is refused, not trimmed." }
        byStaffId: { type: string, nullable: true }
      required: [customerId, orderId, points]
    LoyaltyRedeemResult:
      type: object
      properties:
        orderId: { type: string }
        customerId: { type: string }
        pointsRedeemed: { type: integer, format: int64 }
        discountCents: { type: integer, format: int64 }
        remainingPoints: { type: integer, format: int64 }
        orderDiscountCents: { type: integer, format: int64, description: "Total order discount, including any voucher already applied." }
        orderOutstandingCents: { type: integer, format: int64 }
      required: [orderId, customerId, pointsRedeemed, discountCents, remainingPoints, orderDiscountCents, orderOutstandingCents]
    LoyaltyReverseRequest:
      type: object
      properties:
        orderId: { type: string }
      required: [orderId]
    LoyaltyReverseResult:
      type: object
      properties:
        orderId: { type: string }
        customerId: { type: string }
        pointsReturned: { type: integer, format: int64 }
        discountRemovedCents: { type: integer, format: int64 }
        remainingPoints: { type: integer, format: int64 }
      required: [orderId, customerId, pointsReturned, discountRemovedCents, remainingPoints]

    # ── Promotions (V25) ──
    PromotionUpsertRequest:
      type: object
      description: |
        Fields are interpreted per `promoType`, and validated server-side:
          • BUY_X_GET_Y — buyQuantity, getQuantity, getDiscountBp (10000 = free).
          • MULTI_BUY — buyQuantity (>= 2) and bundlePriceCents.
          • BASKET_PERCENT — value as basis points, 1..10000.
          • BASKET_FIXED — value as cents.
        On create `name` and `promoType` are required; on update all are keep-on-absent.
      properties:
        name: { type: string, nullable: true }
        description: { type: string, nullable: true }
        promoType: { type: string, nullable: true, enum: [BUY_X_GET_Y, MULTI_BUY, BASKET_PERCENT, BASKET_FIXED] }
        buyQuantity: { type: integer, nullable: true }
        getQuantity: { type: integer, nullable: true }
        getDiscountBp: { type: integer, nullable: true, description: "1..10000; 10000 = free, 5000 = half price. 0 is refused." }
        bundlePriceCents: { type: integer, format: int64, nullable: true }
        value: { type: integer, format: int64, nullable: true }
        minBasketCents: { type: integer, format: int64, nullable: true, description: "Gate on the WHOLE basket, even when the discount is scoped." }
        scopeType: { type: string, nullable: true, enum: [ALL, CATEGORY, PRODUCT] }
        scopeIds: { type: array, nullable: true, items: { type: string } }
        daysOfWeek: { type: string, nullable: true, description: "Letters from MTWRFSU, same encoding as price rules." }
        startMinute: { type: integer, nullable: true, description: "0..1439. A start after the end is an overnight window and fires across midnight." }
        endMinute: { type: integer, nullable: true }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        locationIds: { type: array, nullable: true, items: { type: string }, description: "Omit for every location." }
        code: { type: string, nullable: true, description: "Omit to auto-apply; set to require the cashier to key it. Unique per tenant, matched case-insensitively." }
        priority: { type: integer, nullable: true, description: "Orders stackable offers, and breaks ties between equal non-stackable ones. It does NOT let a weaker offer beat a better one." }
        stackable: { type: boolean, nullable: true, description: "False (default) means only the single best-value qualifying offer applies." }
        maxApplicationsPerSale: { type: integer, nullable: true, description: "Omit for as many as the basket allows." }
        usageLimit: { type: integer, format: int64, nullable: true, description: "Sales, not applications, over the promotion's life." }
        active: { type: boolean, nullable: true }
    Promotion:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        promoType: { type: string }
        buyQuantity: { type: integer }
        getQuantity: { type: integer }
        getDiscountBp: { type: integer }
        bundlePriceCents: { type: integer, format: int64, nullable: true }
        value: { type: integer, format: int64 }
        minBasketCents: { type: integer, format: int64 }
        scopeType: { type: string }
        scopeIds: { type: array, items: { type: string } }
        daysOfWeek: { type: string }
        startMinute: { type: integer }
        endMinute: { type: integer }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        locationIds: { type: array, nullable: true, items: { type: string }, description: "Null = every location; an empty list would mean none." }
        code: { type: string, nullable: true }
        priority: { type: integer }
        stackable: { type: boolean }
        maxApplicationsPerSale: { type: integer, nullable: true }
        usageLimit: { type: integer, format: int64, nullable: true }
        usageCount: { type: integer, format: int64 }
        active: { type: boolean }
      required: [id, name, promoType, buyQuantity, getQuantity, getDiscountBp, value, minBasketCents, scopeType, daysOfWeek, startMinute, endMinute, priority, stackable, usageCount, active]
    EvaluateRequest:
      type: object
      properties:
        orderId: { type: string }
        codes: { type: array, items: { type: string }, description: "Codes the cashier has keyed." }
      required: [orderId]
    PromotionAward:
      type: object
      properties:
        promotionId: { type: string }
        name: { type: string }
        type: { type: string }
        applications: { type: integer }
        discountCents: { type: integer, format: int64 }
        affectedLineIds: { type: array, items: { type: string } }
        detail: { type: string, description: "Human-facing summary, e.g. \"2 x buy 2 get 1 free\"." }
      required: [promotionId, name, type, applications, discountCents, detail]
    PromotionSkip:
      type: object
      properties:
        promotionId: { type: string }
        name: { type: string }
        reason:
          type: string
          enum: [INACTIVE, OUT_OF_WINDOW, WRONG_LOCATION, CODE_NOT_ENTERED, USAGE_LIMIT_REACHED,
                 NOT_ENOUGH_QUALIFYING_ITEMS, BELOW_MINIMUM_BASKET, NO_DISCOUNT_EARNED,
                 SUPERSEDED_BY_BETTER_OFFER, NOTHING_LEFT_TO_DISCOUNT]
      required: [promotionId, name, reason]
    PromotionEvaluation:
      type: object
      properties:
        totalDiscountCents: { type: integer, format: int64 }
        awards: { type: array, items: { $ref: "#/components/schemas/PromotionAward" } }
        skipped: { type: array, items: { $ref: "#/components/schemas/PromotionSkip" } }
      required: [totalDiscountCents]
    ApplyPromotionsResponse:
      type: object
      properties:
        orderId: { type: string }
        promotionDiscountCents: { type: integer, format: int64 }
        awards: { type: array, items: { $ref: "#/components/schemas/PromotionAward" } }
        skipped: { type: array, items: { $ref: "#/components/schemas/PromotionSkip" } }
      required: [orderId, promotionDiscountCents]
    PromotionPerformance:
      type: object
      properties:
        promotionId: { type: string }
        name: { type: string }
        usageCount: { type: integer, format: int64, description: "The counter the engine gates on." }
        usageLimit: { type: integer, format: int64, nullable: true }
        salesCount: { type: integer, description: "Derived from redemption rows; the truth if it disagrees with usageCount." }
        totalApplications: { type: integer }
        totalDiscountCents: { type: integer, format: int64 }
        averageDiscountCents: { type: integer, format: int64, nullable: true, description: "Null when it has never fired — distinct from an average of 0." }
      required: [promotionId, name, usageCount, salesCount, totalApplications, totalDiscountCents]

    # ── Composite bundles (V26) ──
    BundleOptionUpsert:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true, description: "The specific SKU when the component is variant-bearing." }
        priceDeltaCents: { type: integer, format: int64, description: "Signed upcharge for a premium pick, applied before apportionment." }
        isDefault: { type: boolean, description: "Pre-selected, so a fixed component needs no tap at the till." }
      required: [productId]
    BundleSlotUpsert:
      type: object
      properties:
        name: { type: string, description: "What the till prompts with, e.g. \"Choose a side\"." }
        quantity: { type: integer, default: 1, description: "How many picks; 2 = \"any two sides\". Must not exceed the option count." }
        required: { type: boolean, default: true, description: "False lets a guest decline. The bundle price is unchanged — declining must not make the meal cheaper for the venue to sell." }
        options: { type: array, items: { $ref: "#/components/schemas/BundleOptionUpsert" } }
      required: [name, options]
    BundleUpsertRequest:
      type: object
      description: |
        pricingMode:
          • FIXED — the bundle product's own priceCents is the bundle price.
          • SUM_MINUS_PCT — components at list, less discountBp.
          • SUM — components at list (a convenience bundle, not a saving).
      properties:
        productId: { type: string }
        pricingMode: { type: string, default: FIXED, enum: [FIXED, SUM_MINUS_PCT, SUM] }
        discountBp: { type: integer, default: 0, description: "Basis points; only read for SUM_MINUS_PCT." }
        description: { type: string, nullable: true }
        active: { type: boolean, default: true }
        slots: { type: array, items: { $ref: "#/components/schemas/BundleSlotUpsert" } }
      required: [productId, slots]
    BundleOption:
      type: object
      properties:
        optionId: { type: string }
        productId: { type: string }
        productName: { type: string }
        variantId: { type: string, nullable: true }
        priceDeltaCents: { type: integer, format: int64 }
        listPriceCents: { type: integer, format: int64 }
        isDefault: { type: boolean }
      required: [optionId, productId, productName]
    BundleSlot:
      type: object
      properties:
        slotId: { type: string }
        name: { type: string }
        quantity: { type: integer }
        required: { type: boolean }
        options: { type: array, items: { $ref: "#/components/schemas/BundleOption" } }
      required: [slotId, name, quantity, required]
    Bundle:
      type: object
      properties:
        productId: { type: string }
        productName: { type: string, nullable: true }
        pricingMode: { type: string }
        discountBp: { type: integer }
        fixedPriceCents: { type: integer, format: int64, description: "The bundle product's own price — the bundle price under FIXED." }
        description: { type: string, nullable: true }
        active: { type: boolean }
        slots: { type: array, items: { $ref: "#/components/schemas/BundleSlot" } }
      required: [productId, pricingMode, discountBp, fixedPriceCents, active]
    BundleSelection:
      type: object
      properties:
        slotId: { type: string }
        optionIds: { type: array, items: { type: string }, description: "One id per pick; a slot taking 2 needs 2." }
      required: [slotId, optionIds]
    BundleQuoteRequest:
      type: object
      properties:
        selections: { type: array, items: { $ref: "#/components/schemas/BundleSelection" }, description: "Omit to price the bundle's defaults." }
    BundleComponent:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        name: { type: string }
        vatType: { type: string, description: "The component's OWN type — a mixed-rate bundle taxes each part correctly." }
        lineTotalCents: { type: integer, format: int64, description: "This component's apportioned share of the bundle price." }
        listPriceCents: { type: integer, format: int64 }
        slotId: { type: string }
        optionId: { type: string }
      required: [productId, name, vatType, lineTotalCents, listPriceCents, slotId, optionId]
    BundleQuote:
      type: object
      properties:
        productId: { type: string }
        bundlePriceCents: { type: integer, format: int64 }
        listTotalCents: { type: integer, format: int64 }
        savingCents: { type: integer, format: int64, description: "List less bundle price. Zero for SUM, and negative if a declined optional slot leaves less on the plate than the fixed price." }
        components: { type: array, items: { $ref: "#/components/schemas/BundleComponent" } }
      required: [productId, bundlePriceCents, listTotalCents, savingCents]
    AddBundleRequest:
      type: object
      properties:
        productId: { type: string }
        selections: { type: array, items: { $ref: "#/components/schemas/BundleSelection" }, description: "Omit to use the defaults." }
        quantity: { type: integer, default: 1, description: "Each becomes an independent bundle instance with its own group id." }
        seatNumber: { type: integer, nullable: true }
        note: { type: string, nullable: true }
      required: [productId]
    AddBundleResponse:
      type: object
      properties:
        orderId: { type: string }
        bundleProductId: { type: string }
        bundleGroupIds: { type: array, items: { type: string }, description: "One per bundle instance." }
        linesAdded: { type: integer }
        bundlePriceCents: { type: integer, format: int64 }
        totalAddedCents: { type: integer, format: int64 }
        savingCents: { type: integer, format: int64 }
        orderSubtotalCents: { type: integer, format: int64 }
      required: [orderId, bundleProductId, linesAdded, bundlePriceCents, totalAddedCents, savingCents, orderSubtotalCents]

    # ── Price lists (V27) ──
    PriceListUpsertRequest:
      type: object
      description: "Keep-on-absent on update; `name` is required on create."
      properties:
        name: { type: string, nullable: true }
        description: { type: string, nullable: true }
        upliftBp: { type: integer, nullable: true, description: "Signed basis points for anything not itemised; 1500 = +15%. Range -10000..100000." }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true, description: "Outside the window the list is ignored and its locations fall back to base prices." }
        active: { type: boolean, nullable: true, description: "False makes every assigned location sell at base without unassigning anything." }
    AssignedLocation:
      type: object
      properties:
        locationId: { type: string }
        locationName: { type: string }
      required: [locationId, locationName]
    CollectSlot:
      type: object
      properties:
        startsAt: { type: integer, format: int64 }
        endsAt: { type: integer, format: int64 }
        available: { type: integer, description: Remaining capacity. Full slots are omitted entirely. }
    CollectSlots:
      type: object
      properties:
        locationId: { type: string }
        leadMinutes: { type: integer, description: Notice required. Rounds UP to the next slot boundary. }
        allowPayOnCollection: { type: boolean }
        slots: { type: array, items: { $ref: "#/components/schemas/CollectSlot" } }
    CollectCatalogueItem:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        priceCents: { type: integer, format: int64, description: Resolved at this location, server-side. }
        vatType: { type: string }
        availableUnits:
          type: integer
          format: int64
          nullable: true
          description: >-
            `on_hand - reserved`, never on-hand. Null for a product that does not track
            stock — nothing to run out of.
    CollectLineRequest:
      type: object
      required: [productId, quantity]
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        quantity: { type: integer, format: int64 }
    PlaceCollectOrderRequest:
      type: object
      required: [contactName, contactPhone, slotStartsAt, lines]
      properties:
        contactName: { type: string }
        contactPhone: { type: string }
        contactEmail: { type: string, nullable: true }
        slotStartsAt: { type: integer, format: int64, description: Validated against the generated slot list. }
        paymentMode: { type: string, enum: [ONLINE, ON_COLLECTION], default: ONLINE }
        clientToken:
          type: string
          nullable: true
          description: Strongly recommended — makes a double-tap idempotent.
        note: { type: string, nullable: true }
        lines: { type: array, items: { $ref: "#/components/schemas/CollectLineRequest" } }
    CollectPlaced:
      type: object
      properties:
        collectOrderId: { type: string }
        reference: { type: string, example: "COL-20260804-000001" }
        status: { type: string }
        totalCents: { type: integer, format: int64 }
        slotStartsAt: { type: integer, format: int64 }
        expiresAt:
          type: integer
          format: int64
          nullable: true
          description: When the hold lapses if unpaid. Null once paid — a paid order never expires.
        duplicate: { type: boolean, description: True when this clientToken had already ordered. }
        unavailableProductIds: { type: array, items: { type: string } }
    CollectTracking:
      type: object
      description: >-
        Deliberately narrow — no phone number, note, reservation flag, expiry or line
        detail. A public surface shows progress, not operational state.
      properties:
        reference: { type: string }
        status: { type: string }
        slotStartsAt: { type: integer, format: int64 }
        slotEndsAt: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        readyForCollection: { type: boolean }
        message: { type: string, description: Progress in words. }
    CollectSettingsRequest:
      type: object
      required: [locationId]
      properties:
        locationId: { type: string }
        enabled: { type: boolean, nullable: true, description: False until a venue opts in. }
        slotMinutes: { type: integer, minimum: 5, maximum: 240, nullable: true }
        slotCapacity: { type: integer, minimum: 1, nullable: true }
        leadMinutes: { type: integer, nullable: true }
        openMinute: { type: integer, nullable: true, description: Minutes from midnight, SAST. }
        closeMinute: { type: integer, nullable: true }
        holdMinutes: { type: integer, minimum: 1, maximum: 1440, nullable: true }
        allowPayOnCollection: { type: boolean, nullable: true }
    CollectSettings:
      type: object
      properties:
        locationId: { type: string }
        enabled: { type: boolean }
        slotMinutes: { type: integer }
        slotCapacity: { type: integer }
        leadMinutes: { type: integer }
        openMinute: { type: integer }
        closeMinute: { type: integer }
        holdMinutes: { type: integer }
        allowPayOnCollection: { type: boolean }
    CollectPayRequest:
      type: object
      required: [amountCents]
      properties:
        method: { type: string, default: CARD }
        amountCents: { type: integer, format: int64 }
        tenderedCents: { type: integer, format: int64, nullable: true }
        providerReference: { type: string, nullable: true }
    CollectMoney:
      type: object
      properties:
        collectOrderId: { type: string }
        orderId: { type: string }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        balanceDueCents: { type: integer, format: int64 }
        status: { type: string }
    CollectHandoverRequest:
      type: object
      properties:
        payments:
          type: array
          items: { $ref: "#/components/schemas/PaymentLeg" }
          description: Only what is left — anything paid online already counts.
    CollectHandover:
      type: object
      properties:
        collectOrderId: { type: string }
        orderId: { type: string }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
    CollectCancelled:
      type: object
      properties:
        collectOrderId: { type: string }
        unitsReleased: { type: integer, format: int64 }
        refundDueCents: { type: integer, format: int64, description: Reported, never silently kept. }
    CollectSweepResult:
      type: object
      properties:
        expired: { type: integer }
        unitsReleased: { type: integer, format: int64 }
        markedShort: { type: integer, description: Orders a walk-in has taken stock from. }
    CollectOrderLine:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        name: { type: string, description: The name the customer saw when ordering. }
        quantity: { type: integer, format: int64 }
        unitPriceCents: { type: integer, format: int64, description: Frozen at order time. }
        lineTotalCents: { type: integer, format: int64 }
        reservedUnits: { type: integer, format: int64, description: Units currently held. Zero after a release. }
    CollectOrder:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        locationId: { type: string }
        locationName: { type: string, nullable: true }
        contactName: { type: string }
        contactPhone: { type: string }
        contactEmail: { type: string, nullable: true }
        slotStartsAt: { type: integer, format: int64 }
        slotEndsAt: { type: integer, format: int64 }
        status:
          type: string
          enum: [PENDING_PAYMENT, RESERVED, READY, SHORT, COLLECTED, CANCELLED, EXPIRED]
        standing: { type: string, description: What to do next, in words. }
        paymentMode: { type: string }
        orderId: { type: string, nullable: true }
        stockReserved: { type: boolean }
        expiresAt: { type: integer, format: int64, nullable: true }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        balanceDueCents: { type: integer, format: int64 }
        minutesWaiting: { type: integer, format: int64 }
        notifiedAt: { type: integer, format: int64, nullable: true }
        note: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/CollectOrderLine" } }
    RotaUpsertRequest:
      type: object
      properties:
        locationId: { type: string }
        staffId: { type: string }
        startsAt: { type: integer, format: int64, description: "Half-open with endsAt, so a 17:00 handover is not an overlap." }
        endsAt: { type: integer, format: int64 }
        breakMinutes: { type: integer, default: 0, description: Unpaid, deducted from the forecast. }
        role: { type: string, nullable: true, description: What they are rostered AS — a supervisor covering the bar is a bar shift. }
        note: { type: string, nullable: true }
    RotaShift:
      type: object
      properties:
        id: { type: string }
        staffId: { type: string }
        staffName: { type: string, nullable: true }
        role: { type: string, nullable: true }
        startsAt: { type: integer, format: int64 }
        endsAt: { type: integer, format: int64 }
        breakMinutes: { type: integer }
        plannedMinutes: { type: integer, format: int64 }
        published: { type: boolean, description: False while the week is a draft — staff cannot see it. }
    RotaStaffForecast:
      type: object
      properties:
        staffId: { type: string }
        staffName: { type: string, nullable: true }
        shifts: { type: integer }
        plannedMinutes: { type: integer, format: int64 }
        forecastCostCents: { type: integer, format: int64 }
    Rota:
      type: object
      properties:
        periodLabel: { type: string }
        periodFrom: { type: integer, format: int64 }
        periodUntil: { type: integer, format: int64 }
        shifts: { type: array, items: { $ref: "#/components/schemas/RotaShift" } }
        forecastCostCents:
          type: integer
          format: int64
          nullable: true
          description: Null for non-managers — cost is a management figure. Priced at TODAY's rate.
        plannedMinutes: { type: integer, format: int64 }
        unpublishedShifts: { type: integer }
        staff: { type: array, items: { $ref: "#/components/schemas/RotaStaffForecast" } }
    RotaPublishRequest:
      type: object
      properties:
        preset: { type: string, default: THIS_WEEK }
        from: { type: integer, format: int64, nullable: true }
        until: { type: integer, format: int64, nullable: true }
        locationId: { type: string, nullable: true }
    RotaPublishResult:
      type: object
      properties:
        periodLabel: { type: string }
        periodFrom: { type: integer, format: int64 }
        periodUntil: { type: integer, format: int64 }
        shiftsPublished: { type: integer, description: Zero on a repeat — already-published shifts are not re-stamped. }
    RotaVarianceLine:
      type: object
      properties:
        staffId: { type: string }
        staffName: { type: string, nullable: true }
        plannedMinutes: { type: integer, format: int64 }
        actualMinutes: { type: integer, format: int64 }
        differenceMinutes: { type: integer, format: int64, description: Positive = worked more than rostered. }
        forecastCostCents: { type: integer, format: int64 }
        actualCostCents: { type: integer, format: int64 }
        differenceCostCents: { type: integer, format: int64 }
        verdict:
          type: string
          description: Proportional, so 15 minutes over a four-hour shift is not flagged while 15 over a 40-hour week is.
          enum: ["On the roster", "Over the roster", "Under the roster", "Rostered but did not clock in", "Worked without being rostered", "Nothing rostered or worked"]
    RotaVariance:
      type: object
      properties:
        periodLabel: { type: string }
        periodFrom: { type: integer, format: int64 }
        periodUntil: { type: integer, format: int64 }
        plannedMinutes: { type: integer, format: int64 }
        actualMinutes: { type: integer, format: int64 }
        forecastCostCents: { type: integer, format: int64 }
        actualCostCents: { type: integer, format: int64 }
        differenceCostCents: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/RotaVarianceLine" } }
    DeliveryPlatformUpsertRequest:
      type: object
      properties:
        platform: { type: string, enum: [UBER_EATS, MR_D, BOLT_FOOD, DELIVEROO, DOORDASH, OTHER] }
        displayName: { type: string, nullable: true }
        commissionBp:
          type: integer
          minimum: 0
          maximum: 10000
          description: Their cut. Above 100% the payout would be negative, so it is refused.
        locationId: { type: string }
        webhookSecret: { type: string, nullable: true, description: Write-only — never returned. }
        active: { type: boolean, nullable: true }
    DeliveryPlatform:
      type: object
      properties:
        id: { type: string }
        platform: { type: string }
        displayName: { type: string }
        commissionBp: { type: integer }
        locationId: { type: string }
        locationName: { type: string, nullable: true }
        active: { type: boolean }
        mappedItems: { type: integer }
        ordersReceived: { type: integer }
        webhookConfigured: { type: boolean, description: Whether a secret is set — never the secret itself. }
    DeliveryItemMapRequest:
      type: object
      required: [externalItemId, productId]
      properties:
        externalItemId: { type: string }
        productId: { type: string }
        variantId: { type: string, nullable: true }
    DeliveryItemMapping:
      type: object
      properties:
        externalItemId: { type: string }
        productId: { type: string }
        productName: { type: string, nullable: true }
        variantId: { type: string, nullable: true }
    DeliveryIngestLine:
      type: object
      required: [externalItemId, name, quantity, unitPriceCents]
      properties:
        externalItemId: { type: string }
        name: { type: string, description: The platform's own wording — it reaches the kitchen ticket. }
        quantity: { type: integer, format: int64 }
        unitPriceCents:
          type: integer
          format: int64
          description: >-
            The platform's price, VAT-inclusive. Theirs, not the catalogue's: an
            aggregator's menu is usually marked up, and re-pricing would disagree with
            the customer's receipt and the platform statement.
    DeliveryIngestRequest:
      type: object
      required: [platformId, externalId, lines]
      properties:
        platformId: { type: string }
        externalId: { type: string, description: Their order id. Half the idempotency key. }
        externalRef: { type: string, nullable: true, description: The short reference printed on the bag. }
        customerName: { type: string, nullable: true }
        customerPhone: { type: string, nullable: true }
        lines: { type: array, items: { $ref: "#/components/schemas/DeliveryIngestLine" } }
    DeliveryIngestResult:
      type: object
      description: "commissionCents + payoutCents always equals grossCents exactly."
      properties:
        deliveryOrderId: { type: string }
        orderId: { type: string, nullable: true, description: The Aura sale this became. }
        duplicate: { type: boolean, description: True when this external id had already been ingested — nothing was created. }
        status: { type: string, enum: [RECEIVED, ACCEPTED, REJECTED, CANCELLED] }
        grossCents: { type: integer, format: int64, description: What the customer paid. This is the sale, and output VAT is owed on it. }
        commissionCents: { type: integer, format: int64, description: The platform's cut — an expense. }
        payoutCents: { type: integer, format: int64, description: What to expect in the bank. }
        unmappedItems:
          type: array
          items: { type: string }
          description: Platform item ids with no product mapped. The order still went through.
    DeliveryOrder:
      type: object
      properties:
        id: { type: string }
        platformId: { type: string }
        platformName: { type: string, nullable: true }
        externalId: { type: string }
        externalRef: { type: string, nullable: true }
        orderId: { type: string, nullable: true }
        status: { type: string }
        grossCents: { type: integer, format: int64 }
        commissionCents: { type: integer, format: int64 }
        payoutCents: { type: integer, format: int64 }
        hasUnmappedItems: { type: boolean }
        unmappedItems: { type: array, items: { type: string } }
        customerName: { type: string, nullable: true }
        receivedAt: { type: integer, format: int64 }
    DeliveryReconciliation:
      type: object
      properties:
        deliveryOrderId: { type: string }
        grossCents: { type: integer, format: int64 }
        expectedPayoutCents: { type: integer, format: int64 }
        actualPayoutCents: { type: integer, format: int64 }
        differenceCents: { type: integer, format: int64, description: Positive = they paid more than expected. }
        matches: { type: boolean, description: True within one cent — the platform may round the other way. }
        impliedCommissionBp: { type: integer, description: The rate the payout implies — what they are really charging. }
        note: { type: string }
    Menu:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        publishedVersion: { type: integer, description: What tills read. 0 = never published, so locations fall back to all products. }
        draftVersion: { type: integer }
        publishedAt: { type: integer, format: int64, nullable: true }
        active: { type: boolean }
        publishedItemCount: { type: integer }
        draftItemCount: { type: integer }
        hasDraftChanges: { type: boolean, description: Whether there is anything to publish. }
        locations: { type: array, items: { type: string } }
    MenuDraft:
      type: object
      properties:
        menuId: { type: string }
        draftVersion: { type: integer }
        publishedVersion: { type: integer }
        draftItemCount: { type: integer }
    MenuPublishResult:
      type: object
      properties:
        menuId: { type: string }
        publishedVersion: { type: integer }
        itemCount: { type: integer }
    EffectiveMenu:
      type: object
      properties:
        locationId: { type: string }
        menuId: { type: string, nullable: true }
        menuName: { type: string, nullable: true }
        version: { type: integer }
        source:
          type: string
          enum: [MENU, ALL_ACTIVE_PRODUCTS]
          description: Said out loud so a till (and whoever is debugging it) knows which mode it is in.
        productIds: { type: array, items: { type: string } }
    InvoiceLineRequest:
      type: object
      required: [description]
      properties:
        description: { type: string }
        quantity: { type: integer, format: int64, default: 1 }
        unitPriceCents: { type: integer, format: int64, default: 0, description: Negative expresses an agreed reduction, visibly. }
        vatType: { type: string, nullable: true, enum: [STANDARD, ZERO, EXEMPT] }
    InvoiceCreateRequest:
      type: object
      properties:
        houseAccountId: { type: string, nullable: true }
        customerId: { type: string, nullable: true }
        locationId: { type: string, nullable: true }
        billToName: { type: string, nullable: true }
        billToEmail: { type: string, nullable: true }
        billToVatNumber: { type: string, nullable: true }
        terms: { type: string, nullable: true }
        note: { type: string, nullable: true }
        transactionIds:
          type: array
          items: { type: string }
          description: Sales to consolidate. Each can only ever appear on one invoice.
        lines: { type: array, items: { $ref: "#/components/schemas/InvoiceLineRequest" } }
    InvoiceLinesRequest:
      type: object
      properties:
        transactionIds: { type: array, items: { type: string } }
        lines: { type: array, items: { $ref: "#/components/schemas/InvoiceLineRequest" } }
    InvoicePaymentRequest:
      type: object
      required: [amountCents]
      properties:
        amountCents: { type: integer, format: int64 }
        method: { type: string, default: EFT }
        reference: { type: string, nullable: true }
        receivedAt: { type: integer, format: int64, nullable: true }
    InvoiceTotals:
      type: object
      properties:
        subtotalCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        lineCount: { type: integer }
    InvoiceLine:
      type: object
      properties:
        transactionId: { type: string, nullable: true, description: The sale billed. Unique per tenant — a sale can only be invoiced once. }
        description: { type: string, description: Survives a product rename or a voided sale. }
        quantity: { type: integer, format: int64 }
        unitPriceCents: { type: integer, format: int64 }
        vatType: { type: string }
        lineTotalCents: { type: integer, format: int64 }
    InvoicePaymentEntry:
      type: object
      properties:
        amountCents: { type: integer, format: int64 }
        method: { type: string }
        reference: { type: string, nullable: true }
        receivedAt: { type: integer, format: int64 }
    Invoice:
      type: object
      properties:
        id: { type: string }
        invoiceNumber: { type: string, example: "INV-20260804-000001" }
        houseAccountId: { type: string, nullable: true }
        customerId: { type: string, nullable: true }
        locationId: { type: string }
        billToName: { type: string, nullable: true, description: Snapshotted at creation — a later contact change does not alter a sent document. }
        billToEmail: { type: string, nullable: true }
        billToVatNumber: { type: string, nullable: true }
        status: { type: string, enum: [DRAFT, ISSUED, PAID, VOID] }
        displayStatus:
          type: string
          enum: [DRAFT, ISSUED, OVERDUE, PAID, VOID]
          description: Derived. An ISSUED invoice past its due date reads OVERDUE; paid beats overdue.
        daysOverdue: { type: integer, format: int64 }
        issuedAt: { type: integer, format: int64, nullable: true }
        dueAt: { type: integer, format: int64, nullable: true }
        paidAt: { type: integer, format: int64, nullable: true }
        voidedAt: { type: integer, format: int64, nullable: true }
        voidReason: { type: string, nullable: true }
        subtotalCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64, description: The portion already inside totalCents. }
        totalCents: { type: integer, format: int64, description: Negative on a credit note. }
        paidCents: { type: integer, format: int64 }
        balanceDueCents: { type: integer, format: int64, description: Never negative. }
        creditNoteOf: { type: string, nullable: true, description: Set on a credit note, pointing at the invoice it corrects. }
        terms: { type: string, nullable: true }
        note: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/InvoiceLine" } }
        payments: { type: array, items: { $ref: "#/components/schemas/InvoicePaymentEntry" } }
    ReportPreset:
      type: object
      properties:
        preset: { type: string, example: LAST_MONTH }
        label: { type: string, example: Last month }
        fromMs: { type: integer, format: int64 }
        untilMs: { type: integer, format: int64, description: Exclusive. In-progress periods end at now. }
    ReportTemplateUpsertRequest:
      type: object
      properties:
        name: { type: string }
        reportType: { type: string, enum: [products, labour, by-dimension] }
        datePreset:
          type: string
          enum: [TODAY, YESTERDAY, THIS_WEEK, LAST_WEEK, THIS_MONTH, LAST_MONTH, LAST_7_DAYS, LAST_30_DAYS, THIS_YEAR, CUSTOM]
          default: LAST_MONTH
          description: The intent, resolved against the clock on every run — never two stored dates.
        customFrom: { type: integer, format: int64, nullable: true, description: CUSTOM only. }
        customUntil: { type: integer, format: int64, nullable: true }
        params:
          type: array
          items: { type: string }
          nullable: true
          description: "`key=value` options, e.g. `dimension=supplier`. Unknown keys are ignored."
        locationId: { type: string, nullable: true, description: Null = every location. }
    ReportTemplate:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        reportType: { type: string }
        datePreset: { type: string }
        resolvedFrom: { type: integer, format: int64, description: What the preset covers right now. Computed live, never stored. }
        resolvedUntil: { type: integer, format: int64 }
        periodLabel: { type: string }
        params: { type: array, items: { type: string } }
        locationId: { type: string, nullable: true }
        locationName: { type: string, nullable: true }
        scheduleCount: { type: integer }
        createdAt: { type: integer, format: int64 }
    RenderedReport:
      type: object
      description: >-
        Columns and rows of strings rather than a typed shape per report, so one CSV
        writer serves every report.
      properties:
        templateId: { type: string }
        reportType: { type: string }
        periodLabel: { type: string }
        periodFrom: { type: integer, format: int64 }
        periodUntil: { type: integer, format: int64 }
        columns: { type: array, items: { type: string } }
        rows:
          type: array
          items: { type: array, items: { type: string } }
        totals: { type: object, additionalProperties: { type: string } }
    ReportScheduleUpsertRequest:
      type: object
      required: [templateId, cadence]
      properties:
        templateId: { type: string }
        cadence: { type: string, enum: [DAILY, WEEKLY, MONTHLY] }
        hourOfDay: { type: integer, minimum: 0, maximum: 23, default: 7, description: SAST. }
        dayOfWeek: { type: integer, minimum: 1, maximum: 7, nullable: true, description: 1=Monday. WEEKLY only. }
        dayOfMonth:
          type: integer
          minimum: 1
          maximum: 28
          nullable: true
          description: Capped at 28 so a monthly report cannot silently skip February.
        recipients:
          type: array
          items: { type: string }
          description: Required and non-empty — a schedule with nobody to send to would succeed and deliver nothing.
        format: { type: string, enum: [CSV, HTML], default: CSV }
        active: { type: boolean, default: true }
    ReportSchedule:
      type: object
      properties:
        id: { type: string }
        templateId: { type: string }
        templateName: { type: string, nullable: true }
        cadence: { type: string, enum: [DAILY, WEEKLY, MONTHLY] }
        hourOfDay: { type: integer }
        dayOfWeek: { type: integer, nullable: true }
        dayOfMonth: { type: integer, nullable: true }
        recipients: { type: array, items: { type: string } }
        format: { type: string }
        active: { type: boolean }
        lastRunAt: { type: integer, format: int64, nullable: true }
        lastStatus: { type: string, nullable: true }
        lastPeriodKey: { type: string, nullable: true }
    ReportDeliveryOutcome:
      type: object
      properties:
        scheduleId: { type: string }
        templateName: { type: string, nullable: true }
        periodKey: { type: string, example: "2026-08-04" }
        status:
          type: string
          enum: [SENT, FAILED, SKIPPED_EMPTY, NOT_CONFIGURED, ALREADY_SENT]
          description: >-
            SKIPPED_EMPTY is a real outcome, not an error — nobody wants an empty
            spreadsheet every Monday, but they do want to know the schedule ran.
            ALREADY_SENT is reported rather than omitted, because "ran, nothing to do"
            and "did not run" are different answers.
        rowCount: { type: integer }
        recipients: { type: array, items: { type: string } }
        error: { type: string, nullable: true }
    ReportDelivery:
      type: object
      properties:
        scheduleId: { type: string }
        templateName: { type: string, nullable: true }
        periodKey: { type: string }
        periodFrom: { type: integer, format: int64, description: The window actually reported on, stored so a delivery can be explained months later. }
        periodUntil: { type: integer, format: int64 }
        status: { type: string }
        rowCount: { type: integer }
        recipients: { type: array, items: { type: string } }
        error: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
    LabourStaffLine:
      type: object
      properties:
        staffId: { type: string }
        staffName: { type: string, nullable: true }
        role: { type: string, nullable: true }
        minutes: { type: integer, format: int64, description: Paid minutes, clipped to the window and net of a pro-rated break. }
        hourlyRateCents: { type: integer, format: int64, description: The rate their shifts were actually costed at, not today's. }
        costCents: { type: integer, format: int64 }
    LabourReport:
      type: object
      properties:
        periodLabel: { type: string }
        periodFrom: { type: integer, format: int64 }
        periodUntil: { type: integer, format: int64 }
        salesCents: { type: integer, format: int64 }
        labourCostCents: { type: integer, format: int64 }
        labourPctOfSalesBp:
          type: integer
          format: int64
          description: Basis points; 2850 = 28.50%. Zero when there were no sales — never infinity.
        paidMinutes: { type: integer, format: int64 }
        salesPerHourCents: { type: integer, format: int64, description: Revenue per paid hour — what a roster is judged on. }
        verdict: { type: string, enum: ["Tight", "Healthy", "Heavy", "Overstaffed for the trade", "No sales in this period"] }
        staff: { type: array, items: { $ref: "#/components/schemas/LabourStaffLine" } }
    DimensionRow:
      type: object
      properties:
        key: { type: string, description: "The raw grouping value; `__unset__` where nothing is recorded." }
        label: { type: string, description: "\"Not recorded\" for the unset bucket." }
        productCount: { type: integer }
        unitsSold: { type: integer, format: int64 }
        revenueCents: { type: integer, format: int64 }
        costCents: { type: integer, format: int64 }
        marginCents: { type: integer, format: int64 }
        marginPctBp: { type: integer, format: int64 }
    DimensionReport:
      type: object
      properties:
        dimension: { type: string, enum: [brand, supplier, category] }
        periodLabel: { type: string }
        periodFrom: { type: integer, format: int64 }
        periodUntil: { type: integer, format: int64 }
        totalRevenueCents: { type: integer, format: int64, description: Equals the sales report's total for the same window — the unset bucket is why. }
        totalMarginCents: { type: integer, format: int64 }
        rows: { type: array, items: { $ref: "#/components/schemas/DimensionRow" } }
    DrilldownLine:
      type: object
      properties:
        transactionId: { type: string }
        transactionNumber: { type: string }
        closedAt: { type: integer, format: int64, nullable: true }
        productId: { type: string }
        productName: { type: string, description: The name as it was on the receipt. }
        quantity: { type: integer, format: int64 }
        lineTotalCents: { type: integer, format: int64, description: Zero for a comped line — it happened, but was given away. }
        comped: { type: boolean }
    CommissionSchemeUpsertRequest:
      type: object
      properties:
        name: { type: string }
        basis:
          type: string
          enum: [LINE_TOTAL, LINE_MARGIN]
          default: LINE_TOTAL
          description: >-
            LINE_MARGIN pays on (charged − cost) and earns nothing at or below cost.
            It needs product cost to be real, which is why it is not the default.
        rateBp:
          type: integer
          minimum: 1
          maximum: 10000
          description: Basis points; 500 = 5%. Capped so a percent/bp mix-up is caught.
        scopeType: { type: string, enum: [ALL, CATEGORY, PRODUCT], default: ALL }
        scopeIds: { type: array, items: { type: string }, nullable: true }
        role: { type: string, nullable: true, description: Null pays every role. A staff member with no role does NOT earn a role-restricted scheme. }
        staffIds: { type: array, items: { type: string }, nullable: true, description: Empty pays everyone. }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        active: { type: boolean, nullable: true }
    CommissionScheme:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        basis: { type: string, enum: [LINE_TOTAL, LINE_MARGIN] }
        rateBp: { type: integer }
        scopeType: { type: string, enum: [ALL, CATEGORY, PRODUCT] }
        scopeIds: { type: array, items: { type: string } }
        role: { type: string, nullable: true }
        staffIds: { type: array, items: { type: string } }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        active: { type: boolean }
        earnedEntries: { type: integer, description: Why it may not be deletable. }
    StaffCommission:
      type: object
      properties:
        staffId: { type: string }
        staffName: { type: string, nullable: true }
        periodKey: { type: string, example: "2026-08" }
        entryCount: { type: integer }
        accruedCents: { type: integer, format: int64, description: Net of reversals. }
        paidCents: { type: integer, format: int64 }
        owedCents: { type: integer, format: int64, description: Never negative. }
    CommissionEntry:
      type: object
      description: "Self-explaining: baseCents × rateBp ÷ 10000 reproduces amountCents."
      properties:
        id: { type: string }
        transactionId: { type: string }
        lineId: { type: string, nullable: true }
        productId: { type: string, nullable: true }
        baseCents:
          type: integer
          format: int64
          description: >-
            What the rate was applied to — the line total net of its own discount and
            its share of any bill discount, or the margin for a LINE_MARGIN scheme.
        rateBp: { type: integer, description: Frozen at accrual, so a later rate change cannot restate this. }
        amountCents: { type: integer, format: int64, description: Negative for a refund reversal. }
        status: { type: string, enum: [ACCRUED, PAID] }
        reversesId: { type: string, nullable: true, description: Present on a reversal; identifies it structurally. }
        createdAt: { type: integer, format: int64 }
    PayCommissionRequest:
      type: object
      properties:
        period: { type: string, nullable: true, description: Defaults to the current month in SAST. }
        reference: { type: string, nullable: true, description: e.g. an EFT reference. }
    PayCommissionResult:
      type: object
      properties:
        staffId: { type: string }
        periodKey: { type: string }
        entriesMarkedPaid: { type: integer, description: Zero on a second attempt. }
        amountPaidCents: { type: integer, format: int64 }
    SegmentRule:
      type: object
      description: >-
        Criteria, ANDed. Every field is nullable and a null field is not tested, so
        an empty object matches every customer — which is the correct reading of
        "no criteria" and how a whole-list campaign is expressed. There is no OR:
        that is two segments.
      properties:
        tiers: { type: array, items: { type: string }, nullable: true }
        minSpendCents: { type: integer, format: int64, nullable: true }
        maxSpendCents: { type: integer, format: int64, nullable: true }
        minVisits: { type: integer, nullable: true }
        maxVisits: { type: integer, nullable: true, description: "0 selects customers who have never visited." }
        minPoints: { type: integer, format: int64, nullable: true }
        lapsedDays:
          type: integer
          nullable: true
          description: >-
            Has visited, but not in this many days. A customer who has NEVER visited
            deliberately does not match — treating a null last-visit as infinitely
            lapsed would send "we miss you" to someone who has never been in.
        activeWithinDays: { type: integer, nullable: true }
        tagsAny:
          type: array
          items: { type: string }
          nullable: true
          description: Case-insensitive. An empty list is treated as no criterion, not as "matches nothing".
        tagsAll: { type: array, items: { type: string }, nullable: true }
        birthdayMonth: { type: integer, minimum: 1, maximum: 12, nullable: true }
        hasPhone: { type: boolean, nullable: true }
        hasEmail: { type: boolean, nullable: true }
        optedInWhatsapp:
          type: boolean
          nullable: true
          description: >-
            Consent as a *selection* criterion. Distinct from the consent check at
            send time, which applies whether or not the segment mentions it — a
            segment cannot opt someone in by omitting this.
        optedInEmail: { type: boolean, nullable: true }
    SegmentUpsertRequest:
      type: object
      properties:
        name: { type: string }
        description: { type: string, nullable: true }
        rule: { $ref: "#/components/schemas/SegmentRule" }
        ruleProvided:
          type: boolean
          default: true
          description: >-
            False updates without touching the criteria. Needed because the default
            rule value is indistinguishable from an omitted one, so a rename would
            otherwise silently reset the segment to "everyone".
        active: { type: boolean, nullable: true }
    SegmentPreviewRequest:
      type: object
      properties:
        rule: { $ref: "#/components/schemas/SegmentRule" }
    SegmentMember:
      type: object
      properties:
        id: { type: string }
        name: { type: string, nullable: true }
        tier: { type: string }
        visitCount: { type: integer }
        totalSpentCents: { type: integer, format: int64 }
        lastVisitAt: { type: integer, format: int64, nullable: true }
    SegmentPreview:
      type: object
      properties:
        description: { type: string, description: The rule in plain English. }
        size: { type: integer }
        reachableWhatsapp: { type: integer, description: How many can actually be messaged — the number that matters. }
        reachableEmail: { type: integer }
        sample: { type: array, items: { $ref: "#/components/schemas/SegmentMember" } }
    Segment:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        ruleDescription: { type: string, description: So nobody has to read the JSON. }
        rule: { $ref: "#/components/schemas/SegmentRule" }
        size: { type: integer, description: Who matches right now — membership is derived, not stored. }
        reachableWhatsapp: { type: integer }
        reachableEmail: { type: integer }
        active: { type: boolean }
        createdAt: { type: integer, format: int64 }
    CampaignUpsertRequest:
      type: object
      properties:
        name: { type: string }
        channel: { type: string, enum: [WHATSAPP, EMAIL] }
        segmentId: { type: string, nullable: true, description: Null means an ad-hoc audience given at send time. }
        subject: { type: string, nullable: true, description: Email only. }
        body:
          type: string
          description: >-
            Template using {name}, {firstName}, {tier}, {points}, {spend}, {credit},
            {venue}, {link}. Unknown tokens are rejected at save time.
        scheduledAt: { type: integer, format: int64, nullable: true }
    SendCampaignRequest:
      type: object
      properties:
        customerIds:
          type: array
          items: { type: string }
          nullable: true
          description: >-
            Overrides the segment. Ids are intersected with the tenant's own
            customers rather than trusted, and a repeated id is messaged once.
    SendCampaignResult:
      type: object
      description: "sentCount + failedCount + suppressedCount always equals audienceCount."
      properties:
        campaignId: { type: string }
        audienceCount: { type: integer }
        sentCount: { type: integer }
        failedCount: { type: integer }
        suppressedCount: { type: integer }
        dryRun:
          type: boolean
          description: >-
            True when the channel has no credentials, so nothing was delivered.
            Reported explicitly rather than counted as success.
    Campaign:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        channel: { type: string, enum: [WHATSAPP, EMAIL] }
        segmentId: { type: string, nullable: true }
        segmentName: { type: string, nullable: true }
        subject: { type: string, nullable: true }
        body: { type: string }
        status: { type: string, enum: [DRAFT, SCHEDULED, SENDING, SENT, CANCELLED, FAILED] }
        scheduledAt: { type: integer, format: int64, nullable: true }
        startedAt: { type: integer, format: int64, nullable: true }
        finishedAt: { type: integer, format: int64, nullable: true }
        audienceCount: { type: integer }
        sentCount: { type: integer }
        failedCount: { type: integer }
        suppressedCount: { type: integer }
        createdAt: { type: integer, format: int64 }
    CampaignRecipient:
      type: object
      properties:
        customerId: { type: string }
        customerName: { type: string, nullable: true }
        channelAddress:
          type: string
          nullable: true
          description: The address as it was at send time — never read live.
        status: { type: string, enum: [PENDING, SENT, FAILED, SUPPRESSED] }
        suppressReason:
          type: string
          nullable: true
          enum: [NO_CONSENT, NO_ADDRESS, DUPLICATE, NOT_CONFIGURED]
          description: >-
            Names whose job the fix is: marketing, data entry, nothing, deployment.
            Kept separate from `error`, which is something that went wrong.
        error: { type: string, nullable: true }
        sentAt: { type: integer, format: int64, nullable: true }
    LifecycleUpsertRequest:
      type: object
      required: [triggerType, channel, body]
      properties:
        triggerType: { type: string, enum: [BIRTHDAY, WIN_BACK, WELCOME, REVIEW_REQUEST] }
        channel: { type: string, enum: [WHATSAPP, EMAIL] }
        subject: { type: string, nullable: true }
        body: { type: string }
        days:
          type: integer
          default: 0
          description: >-
            WIN_BACK: days of silence before it fires (must be at least 1).
            WELCOME / REVIEW_REQUEST: days to wait after the visit, so the message
            does not arrive while the guest is still at the table. Ignored by
            BIRTHDAY.
        active: { type: boolean, default: true }
    LifecycleAutomation:
      type: object
      properties:
        id: { type: string }
        triggerType: { type: string, enum: [BIRTHDAY, WIN_BACK, WELCOME, REVIEW_REQUEST] }
        channel: { type: string, enum: [WHATSAPP, EMAIL] }
        subject: { type: string, nullable: true }
        body: { type: string }
        days: { type: integer }
        active: { type: boolean }
        lastRunAt: { type: integer, format: int64, nullable: true }
        sentTotal: { type: integer }
    LifecycleRunResult:
      type: object
      properties:
        triggerType: { type: string }
        considered: { type: integer }
        sent: { type: integer }
        skippedAlreadySent:
          type: integer
          description: Already sent for this occurrence — the dedupe log doing its job, not an error.
        suppressed: { type: integer }
        failed: { type: integer }
    ConsentRequest:
      type: object
      required: [channel, optedIn]
      properties:
        channel: { type: string, enum: [WHATSAPP, EMAIL] }
        optedIn: { type: boolean }
        source:
          type: string
          default: BACK_OFFICE
          enum: [TILL, BACK_OFFICE, QR, IMPORT, UNSUBSCRIBE_LINK]
        note: { type: string, nullable: true, description: e.g. the words the customer used. }
    ConsentEvent:
      type: object
      properties:
        channel: { type: string }
        optedIn: { type: boolean }
        source: { type: string }
        note: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
    ContactHistoryEntry:
      type: object
      properties:
        campaignId: { type: string }
        campaignName: { type: string }
        channel: { type: string }
        status: { type: string }
        suppressReason: { type: string, nullable: true }
        sentAt: { type: integer, format: int64, nullable: true }
        createdAt: { type: integer, format: int64 }
    Quote:
      type: object
      description: >-
        A priced offer. Reserves no stock and holds no money — `depositDueCents` is a
        policy figure printed on the document, not funds received.
      properties:
        id: { type: string }
        reference: { type: string, example: "QT-20260804-000001" }
        locationId: { type: string }
        locationName: { type: string, nullable: true }
        customerId: { type: string, nullable: true }
        customerName: { type: string, nullable: true }
        contactName: { type: string, nullable: true, description: "For a phone enquiry with no customer record yet." }
        contactPhone: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        title: { type: string, nullable: true }
        status:
          type: string
          description: The stored status. EXPIRED is never stored.
          enum: [DRAFT, SENT, ACCEPTED, DECLINED, CONVERTED, CANCELLED]
        displayStatus:
          type: string
          description: "[status], except a SENT quote past its date reads EXPIRED."
          enum: [DRAFT, SENT, EXPIRED, ACCEPTED, DECLINED, CONVERTED, CANCELLED]
        expired: { type: boolean }
        validUntil:
          type: integer
          format: int64
          nullable: true
          description: >-
            How long the PRICE stands, epoch millis. Null never expires — an open-ended
            standing price for a corporate account is legitimate. Inclusive of its final
            instant.
        eventAt:
          type: integer
          format: int64
          nullable: true
          description: When the function happens — unrelated to validUntil, which is price validity.
        subtotalCents: { type: integer, format: int64 }
        discountCents: { type: integer, format: int64 }
        vatCents:
          type: integer
          format: int64
          description: The 15/115 portion already inside totalCents, not an addition to it.
        totalCents: { type: integer, format: int64 }
        depositDueCents: { type: integer, format: int64, description: What the quote asks for up front. Printed, not held. }
        note: { type: string, nullable: true }
        terms: { type: string, nullable: true }
        convertedOrderId: { type: string, nullable: true }
        convertedAt: { type: integer, format: int64, nullable: true }
        sentAt: { type: integer, format: int64, nullable: true }
        acceptedAt: { type: integer, format: int64, nullable: true }
        declinedAt: { type: integer, format: int64, nullable: true }
        declineReason: { type: string, nullable: true }
        cancelledAt: { type: integer, format: int64, nullable: true }
        createdAt: { type: integer, format: int64 }
        lines: { type: array, items: { $ref: "#/components/schemas/QuoteLine" } }
    QuoteLine:
      type: object
      properties:
        id: { type: string }
        productId:
          type: string
          nullable: true
          description: Null = a free-text line (labour, venue hire, delivery) carrying no stock or cost.
        variantId: { type: string, nullable: true }
        description:
          type: string
          description: Always present — the customer reads this, and products get renamed.
        quantity: { type: integer, format: int64 }
        unitPriceCents:
          type: integer
          format: int64
          description: Frozen when the line was written, VAT-inclusive.
        discountCents: { type: integer, format: int64 }
        vatType:
          type: string
          description: Snapshotted from the product, so a VAT reclassification cannot restate a sent quote.
          enum: [STANDARD, ZERO, EXEMPT]
        lineTotalCents: { type: integer, format: int64 }
        note: { type: string, nullable: true }
        sortOrder: { type: integer }
    QuoteLineRequest:
      type: object
      required: [description]
      properties:
        productId: { type: string, nullable: true, description: Omit for a free-text line. }
        variantId: { type: string, nullable: true, description: Requires productId. }
        description: { type: string }
        quantity: { type: integer, format: int64, default: 1 }
        unitPriceCents:
          type: integer
          format: int64
          nullable: true
          description: >-
            Omit to price from the catalogue at the quote's location and freeze the
            result. A value overrides that — a negotiated number is the point of quoting.
        discountCents: { type: integer, format: int64, default: 0 }
        vatType: { type: string, nullable: true, enum: [STANDARD, ZERO, EXEMPT] }
        note: { type: string, nullable: true }
    QuoteCreateRequest:
      type: object
      required: [locationId, lines]
      properties:
        locationId: { type: string }
        customerId: { type: string, nullable: true }
        contactName: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        title: { type: string, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        eventAt: { type: integer, format: int64, nullable: true }
        note: { type: string, nullable: true }
        terms: { type: string, nullable: true }
        discountCents: { type: integer, format: int64, default: 0 }
        depositPctBp:
          type: integer
          format: int64
          default: 0
          description: "Deposit policy in basis points; 3000 = 30%. Rounded up to the cent."
        byStaffId: { type: string, nullable: true }
        lines: { type: array, items: { $ref: "#/components/schemas/QuoteLineRequest" } }
    QuoteLinesRequest:
      type: object
      required: [lines]
      properties:
        lines: { type: array, items: { $ref: "#/components/schemas/QuoteLineRequest" } }
        discountCents: { type: integer, format: int64, nullable: true, description: Omit to keep the existing header discount. }
        depositPctBp: { type: integer, format: int64, default: 0 }
    QuoteTotals:
      type: object
      properties:
        subtotalCents: { type: integer, format: int64 }
        discountCents: { type: integer, format: int64 }
        vatCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        depositDueCents: { type: integer, format: int64 }
    QuoteConvertRequest:
      type: object
      properties:
        orderType: { type: string, default: TAKEAWAY }
        tableId: { type: string, nullable: true }
        covers: { type: integer, nullable: true }
        byStaffId: { type: string, nullable: true }
    QuoteConverted:
      type: object
      properties:
        quoteId: { type: string }
        orderId: { type: string }
        orderNumber: { type: string, example: "POS-20260804-000011" }
    SpecialOrder:
      type: object
      description: >-
        Goods brought in for one named customer. The deposit is not a field here — it is
        a CAPTURED payment on `orderId`, surfaced as `depositPaidCents`.
      properties:
        id: { type: string }
        reference: { type: string, example: "SO-20260804-000001" }
        locationId: { type: string }
        locationName: { type: string, nullable: true }
        customerId: { type: string, description: Required — an order held for nobody is just stock. }
        customerName: { type: string, nullable: true }
        customerPhone: { type: string, nullable: true }
        description: { type: string, description: What was asked for, in the customer's words. }
        productId: { type: string, nullable: true, description: Often null — that is usually why it is special. }
        variantId: { type: string, nullable: true }
        supplierId: { type: string, nullable: true }
        supplierName: { type: string, nullable: true }
        quantityUnits: { type: integer, format: int64 }
        unitPriceCents:
          type: integer
          format: int64
          nullable: true
          description: Null = not yet priced, which differs from priced at zero (a warranty replacement).
        totalCents: { type: integer, format: int64, nullable: true }
        status:
          type: string
          enum: [REQUESTED, ORDERED, READY, COLLECTED, CANCELLED]
        orderId: { type: string, nullable: true, description: The sale carrying the deposit. Null until priced. }
        purchaseOrderId: { type: string, nullable: true }
        depositPaidCents: { type: integer, format: int64 }
        balanceDueCents: { type: integer, format: int64, nullable: true }
        expectedAt: { type: integer, format: int64, nullable: true }
        orderedAt: { type: integer, format: int64, nullable: true }
        readyAt: { type: integer, format: int64, nullable: true }
        notifiedAt:
          type: integer
          format: int64
          nullable: true
          description: Whether the customer was told — a fact about a message, not a stage of the goods.
        collectedAt: { type: integer, format: int64, nullable: true }
        cancelledAt: { type: integer, format: int64, nullable: true }
        cancelReason: { type: string, nullable: true }
        daysWaiting: { type: integer, format: int64, description: Whole days on the shelf. }
        note: { type: string, nullable: true }
        createdAt: { type: integer, format: int64 }
    SpecialOrderCreateRequest:
      type: object
      required: [locationId, customerId, description]
      properties:
        locationId: { type: string }
        customerId: { type: string }
        description: { type: string }
        productId: { type: string, nullable: true }
        variantId: { type: string, nullable: true }
        supplierId: { type: string, nullable: true }
        quantityUnits: { type: integer, format: int64, default: 1 }
        unitPriceCents: { type: integer, format: int64, nullable: true }
        expectedAt: { type: integer, format: int64, nullable: true }
        note: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
    SpecialOrderPriceRequest:
      type: object
      properties:
        unitPriceCents:
          type: integer
          format: int64
          nullable: true
          description: Omit for a catalogue item to price it at the order's location.
        productId: { type: string, nullable: true, description: Attach a catalogue item to a request that started as text. }
        variantId: { type: string, nullable: true }
        quantityUnits: { type: integer, format: int64, nullable: true }
        depositPctBp: { type: integer, format: int64, default: 0, description: Only suggests a figure back; nothing is collected. }
        byStaffId: { type: string, nullable: true }
    SpecialOrderPriced:
      type: object
      properties:
        id: { type: string }
        orderId: { type: string }
        unitPriceCents: { type: integer, format: int64 }
        totalCents: { type: integer, format: int64 }
        suggestedDepositCents: { type: integer, format: int64 }
    SpecialOrderDepositRequest:
      type: object
      required: [amountCents]
      properties:
        method: { type: string, default: CASH }
        amountCents: { type: integer, format: int64 }
        tenderedCents: { type: integer, format: int64, nullable: true, description: Cash tendered, so change can be worked out. }
        providerReference: { type: string, nullable: true }
        last4: { type: string, nullable: true }
        byStaffId: { type: string, nullable: true }
    SpecialOrderMoney:
      type: object
      properties:
        id: { type: string }
        orderId: { type: string }
        totalCents: { type: integer, format: int64 }
        paidCents: { type: integer, format: int64 }
        balanceDueCents: { type: integer, format: int64 }
    SpecialOrderOrderedRequest:
      type: object
      properties:
        purchaseOrderId: { type: string, nullable: true }
        supplierId: { type: string, nullable: true }
        expectedAt: { type: integer, format: int64, nullable: true }
    SpecialOrderCollectRequest:
      type: object
      properties:
        payments:
          type: array
          description: Only needs to cover the balance — the deposit is already captured.
          items: { $ref: "#/components/schemas/PaymentLeg" }
        byStaffId: { type: string, nullable: true }
    SpecialOrderCollected:
      type: object
      properties:
        id: { type: string }
        orderId: { type: string }
        totalCents: { type: integer, format: int64, description: The full price, not the balance. }
        paidCents: { type: integer, format: int64 }
    SpecialOrderCancelled:
      type: object
      properties:
        id: { type: string }
        depositToRefundCents:
          type: integer
          format: int64
          description: Money already taken that still needs returning. Reported, never dropped.
    PriceList:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        upliftBp: { type: integer }
        validFrom: { type: integer, format: int64, nullable: true }
        validUntil: { type: integer, format: int64, nullable: true }
        active: { type: boolean }
        itemCount: { type: integer, description: "SKUs with an explicit price; the rest follow upliftBp." }
        locations: { type: array, items: { $ref: "#/components/schemas/AssignedLocation" } }
      required: [id, name, upliftBp, active, itemCount]
    PriceListItemUpsert:
      type: object
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true, description: "Null prices the product for every variant; the variant delta still applies." }
        priceCents: { type: integer, format: int64, description: "Absolute, never a delta — a delta would mean editing a base price silently moves every location." }
      required: [productId, priceCents]
    PriceListItemsRequest:
      type: object
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/PriceListItemUpsert" } }
      required: [items]
    PriceListItem:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        productName: { type: string }
        variantId: { type: string, nullable: true }
        priceCents: { type: integer, format: int64 }
        basePriceCents: { type: integer, format: int64, description: "Carried so a UI can show what the list changes." }
      required: [id, productId, productName, priceCents, basePriceCents]
    AssignRequest:
      type: object
      properties:
        locationIds: { type: array, items: { type: string } }
      required: [locationIds]
    PricePreview:
      type: object
      description: |
        Why a price is what it is. The layers apply in order: base → price list →
        rules, and a rule discounts the *location's* price rather than the base one.
      properties:
        productId: { type: string }
        variantId: { type: string, nullable: true }
        locationId: { type: string, nullable: true }
        basePriceCents: { type: integer, format: int64, description: "Product price plus variant delta." }
        listPriceCents: { type: integer, format: int64, description: "After the location's price list; equals the base when there is none." }
        priceCents: { type: integer, format: int64, description: "Final, after any rule." }
        priceListId: { type: string, nullable: true }
        priceListName: { type: string, nullable: true }
        itemisedByList: { type: boolean, description: "True when an explicit item priced it, false when the blanket uplift did." }
        appliedRuleId: { type: string, nullable: true }
        ruleDiscountCents: { type: integer, format: int64, description: "What the rule took off the list price." }
      required: [productId, basePriceCents, listPriceCents, priceCents, itemisedByList, ruleDiscountCents]
