openapi: 3.1.0
info:
  title: Loyattio API
  version: 0.1.0
  description: |
    **Loyattio** is a POS-first, multi-tenant loyalty platform.

    Each merchant on the platform runs an independent loyalty program with its own
    rules, tiers, rewards, customers and ledger. The API is designed to be called
    directly from a POS at checkout, and is built around the following contract:

    - **Preview** rewards for a cart (pure, no writes)
    - **Apply** an order idempotently (commits ledger changes)
    - **Redeem** points or rewards at checkout
    - **Void** an order cleanly on refund

    ### Versioning
    All endpoints are prefixed with `/v1`. Breaking changes increment the major version.

    ### Authentication
    All requests must carry a `Bearer` JWT. Admin tokens scope to the merchant account;
    customer tokens scope to a single customer identity. POS tokens scope to a store +
    employee pair and are short-lived (15 min) with refresh.

    ### Idempotency
    Every `POST` that mutates ledger state accepts an `Idempotency-Key` header (UUIDv4).
    Requests with the same key within 24 h return the original response without
    re-applying side effects.

    ### Money
    Amounts are represented as integer minor units (e.g. HKD 10.50 → `1050`).
    Floats are rejected at the API boundary.
  contact:
    name: Loyattio Platform
    url: https://loyattio.example.com
  license:
    name: Proprietary
servers:
  - url: https://api.loyattio.example.com/v1
    description: Production
  - url: https://api.sandbox.loyattio.example.com/v1
    description: Sandbox
tags:
  - name: Authorization
    description: Merchant, customer and POS authentication.
  - name: Merchants
    description: Merchant account onboarding and profile.
  - name: Stores
    description: Physical branches belonging to a merchant.
  - name: Employees
    description: Cashier and manager accounts.
  - name: Customers
    description: End-customer identities, search and wallet access.
  - name: Catalog
    description: Product categories and products.
  - name: Loyalty Programs
    description: Programs, tiers, rules and rewards.
  - name: Enrollment
    description: Attaching customers to a loyalty program.
  - name: POS
    description: |
      **Hot-path endpoints** the POS calls at checkout.
      Preview → Apply → Void is the canonical sequence.
  - name: Ledger
    description: Read-only views on the double-entry loyalty journal.
  - name: Wallet
    description: Customer-facing wallet and reward balances.
  - name: Cards
    description: |
      Stored-value card domain. Per-card balance, register / topup / transfer /
      report-lost / replace, plus a per-card audit log (statement). Separate
      from program_currency (loyalty) — see the three-layer vocabulary in the
      POS integration guide.
  - name: Payment Spend Policy
    description: |
      How POS draws from each value source at checkout. CRUD over the
      payment_spend_policy table; enforcement happens inside POS apply.
  - name: Webhooks
    description: Outbound event subscriptions.
  - name: Health
    description: Health and readiness probes.

x-tagGroups:
  - name: Platform
    tags:
      - Authorization
      - Merchants
      - Stores
      - Employees
  - name: Commerce
    tags:
      - Customers
      - Catalog
  - name: Loyalty
    tags:
      - Loyalty Programs
      - Enrollment
      - POS
      - Ledger
      - Wallet
      - Cards
      - Payment Spend Policy
  - name: Integrations
    tags:
      - Webhooks
      - Health

security:
  - bearerAuth: []

paths:
  # ─── Authorization ────────────────────────────────────────────
  /auth/merchants/signup:
    post:
      tags: [Authorization]
      summary: Sign up a merchant
      description: Creates a new merchant account, its default store, owner employee, and user profile.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MerchantSignupRequest' }
      responses:
        '201':
          description: Merchant created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MerchantSignupResponse' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /auth/merchants/login:
    post:
      tags: [Authorization]
      summary: Log in a merchant user
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginRequest' }
      responses:
        '200':
          description: Logged in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /auth/pos/token:
    post:
      tags: [Authorization]
      summary: Exchange cashier credentials for a POS token
      description: |
        POS tokens are short-lived (15 min) and scoped to a single `storeId` + `employeeId`.
        Refresh via the returned `refreshToken`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PosTokenRequest' }
      responses:
        '200':
          description: POS token
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PosTokenResponse' }

  /auth/me:
    get:
      tags: [Authorization]
      summary: Current principal
      responses:
        '200':
          description: Caller identity
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Principal' }

  # ─── Merchants ────────────────────────────────────────────────
  /merchants/me:
    get:
      tags: [Merchants]
      summary: Get the current merchant profile
      responses:
        '200':
          description: Merchant
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Merchant' }
    patch:
      tags: [Merchants]
      summary: Update the current merchant profile
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MerchantUpdate' }
      responses:
        '200':
          description: Updated merchant
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Merchant' }

  # ─── Stores ───────────────────────────────────────────────────
  /stores:
    get:
      tags: [Stores]
      summary: List stores
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PageSize' }
      responses:
        '200':
          description: Store list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/Store' }
    post:
      tags: [Stores]
      summary: Create a store
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StoreCreate' }
      responses:
        '201':
          description: Store created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Store' }

  /stores/{storeId}:
    parameters:
      - { $ref: '#/components/parameters/StoreId' }
    get:
      tags: [Stores]
      summary: Get a store
      responses:
        '200':
          description: Store
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Store' }
    patch:
      tags: [Stores]
      summary: Update a store
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StoreUpdate' }
      responses:
        '200':
          description: Updated store
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Store' }
    delete:
      tags: [Stores]
      summary: Delete a store
      responses:
        '204': { description: Deleted }

  # ─── Employees ────────────────────────────────────────────────
  /employees:
    get:
      tags: [Employees]
      summary: List employees
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PageSize' }
        - in: query
          name: storeId
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Employee list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/Employee' }
    post:
      tags: [Employees]
      summary: Create an employee
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EmployeeCreate' }
      responses:
        '201':
          description: Employee created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Employee' }

  # ─── Customers ────────────────────────────────────────────────
  /customers:
    get:
      tags: [Customers]
      summary: List customers
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PageSize' }
        - in: query
          name: q
          schema: { type: string }
          description: Free-text search over name, email, phone and handle.
      responses:
        '200':
          description: Customer list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/Customer' }
    post:
      tags: [Customers]
      summary: Create a customer
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerCreate' }
      responses:
        '201':
          description: Customer created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }

  /customers/{customerId}:
    parameters:
      - { $ref: '#/components/parameters/CustomerId' }
    get:
      tags: [Customers]
      summary: Get a customer
      responses:
        '200':
          description: Customer
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
    patch:
      tags: [Customers]
      summary: Update a customer
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerUpdate' }
      responses:
        '200':
          description: Updated customer
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }

  /customers/lookup:
    post:
      tags: [Customers]
      summary: Look up a customer by phone, email, handle or loyalty card
      description: |
        Fast lookup endpoint intended for the POS at card-swipe or phone-entry.
        Returns at most one customer. P99 latency budget: **80 ms**.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerLookupRequest' }
      responses:
        '200':
          description: Match or empty
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerLookupResponse' }

  # ─── Catalog ──────────────────────────────────────────────────
  /categories:
    get:
      tags: [Catalog]
      summary: List product categories
      responses:
        '200':
          description: Categories
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Category' }
    post:
      tags: [Catalog]
      summary: Create a category
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CategoryCreate' }
      responses:
        '201':
          description: Category
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Category' }

  /products:
    get:
      tags: [Catalog]
      summary: List products
      parameters:
        - in: query
          name: categoryId
          schema: { type: string, format: uuid }
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PageSize' }
      responses:
        '200':
          description: Products
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/Product' }
    post:
      tags: [Catalog]
      summary: Create a product
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductCreate' }
      responses:
        '201':
          description: Product
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }

  # ─── Loyalty Programs ─────────────────────────────────────────
  /loyalty-programs:
    get:
      tags: [Loyalty Programs]
      summary: List loyalty programs
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [active, inactive, draft] }
      responses:
        '200':
          description: Programs
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LoyaltyProgram' }
    post:
      tags: [Loyalty Programs]
      summary: Create a loyalty program
      description: |
        Creates a program, its tiers, and an auto-generated tier-points reward
        type + reward + default rule ("spend N currency = 1 point").
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoyaltyProgramCreate' }
      responses:
        '201':
          description: Program
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoyaltyProgram' }

  /loyalty-programs/{programId}:
    parameters:
      - { $ref: '#/components/parameters/ProgramId' }
    get:
      tags: [Loyalty Programs]
      summary: Get a loyalty program
      responses:
        '200':
          description: Program
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoyaltyProgram' }
    patch:
      tags: [Loyalty Programs]
      summary: Update a loyalty program
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoyaltyProgramUpdate' }
      responses:
        '200':
          description: Updated program
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoyaltyProgram' }

  /loyalty-programs/{programId}/tiers:
    parameters:
      - { $ref: '#/components/parameters/ProgramId' }
    get:
      tags: [Loyalty Programs]
      summary: List tiers
      responses:
        '200':
          description: Tiers
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Tier' }
    post:
      tags: [Loyalty Programs]
      summary: Create a tier
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TierCreate' }
      responses:
        '201':
          description: Tier
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tier' }

  /loyalty-programs/{programId}/rewards:
    parameters:
      - { $ref: '#/components/parameters/ProgramId' }
    get:
      tags: [Loyalty Programs]
      summary: List rewards for a program
      responses:
        '200':
          description: Rewards
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Reward' }
    post:
      tags: [Loyalty Programs]
      summary: Create a reward
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RewardCreate' }
      responses:
        '201':
          description: Reward
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reward' }

  /loyalty-programs/{programId}/rules:
    parameters:
      - { $ref: '#/components/parameters/ProgramId' }
    get:
      tags: [Loyalty Programs]
      summary: List rules
      parameters:
        - in: query
          name: type
          schema: { type: string, enum: [reward, tier] }
      responses:
        '200':
          description: Rules
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Rule' }
    post:
      tags: [Loyalty Programs]
      summary: Create a rule
      description: |
        Rules are authored as JSON in the [json-rules-engine](https://github.com/CacheControl/json-rules-engine)
        format. Available facts and action types are listed in **Rule authoring**.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RuleCreate' }
      responses:
        '201':
          description: Rule
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Rule' }

  # ─── Enrollment ───────────────────────────────────────────────
  /enrollments:
    post:
      tags: [Enrollment]
      summary: Enroll a customer into a program
      description: |
        Idempotent. Creates the ledger, ledger-tier, wallet line item and
        genesis blocks for each essential reward. If the customer is already
        enrolled, returns the existing enrollment.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EnrollmentRequest' }
      responses:
        '201':
          description: Enrolled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Enrollment' }
        '200':
          description: Already enrolled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Enrollment' }

  # ─── POS (hot path) ───────────────────────────────────────────
  /pos/orders/preview:
    post:
      tags: [POS]
      summary: Preview rewards for a cart
      description: |
        **Pure.** No writes, no side effects. Returns the rewards and tier-ups
        that *would* apply if the cart were checked out now.

        Latency budget: **150 ms p99.** Safe to call on every cart mutation.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKeyOptional' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderPreviewRequest' }
      responses:
        '200':
          description: Preview
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPreviewResponse' }

  /pos/orders/apply:
    post:
      tags: [POS]
      summary: Apply a sales order and commit loyalty effects
      description: |
        Writes the sales order, `loyalty_spent` row, ledger blocks and
        transactions in a single database transaction.
        **Idempotent via `Idempotency-Key` header.**

        Latency budget: **300 ms p99.**
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderApplyRequest' }
      responses:
        '201':
          description: Applied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderApplyResponse' }
        '200':
          description: Replay — same idempotency key, original response
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderApplyResponse' }

  /pos/orders/{orderId}/void:
    parameters:
      - { $ref: '#/components/parameters/OrderId' }
    post:
      tags: [POS]
      summary: Void (refund) an applied order
      description: |
        Appends offsetting ledger blocks for every earn, and re-credits any
        redeemed points. Idempotent.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderVoidRequest' }
      responses:
        '200':
          description: Voided
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderVoidResponse' }

  # ─── Cards ────────────────────────────────────────────────────
  /cards:
    post:
      tags: [Cards]
      summary: Register a stored-value card
      description: |
        Activation balance must be 0 (register-then-load) or ≥ minimum
        configured by the channel (HK$50 / MOP$50 POS floor). Per-card balance
        cannot exceed HK$4000.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterCardRequest' }
      responses:
        '201': { description: Registered, content: { application/json: { schema: { $ref: '#/components/schemas/Card' } } } }
        '409': { description: Duplicate barcode, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /cards/by-barcode:
    get:
      tags: [Cards]
      summary: Look up a card by barcode (POS scan flow)
      parameters:
        - in: query
          name: barcode
          required: true
          schema: { type: string, minLength: 4, maxLength: 40 }
      responses:
        '200': { description: Found, content: { application/json: { schema: { $ref: '#/components/schemas/Card' } } } }
        '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /pos/customers/{customerId}/summary:
    parameters:
      - in: path
        name: customerId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [POS]
      summary: Aggregated customer view for POS scan flow
      description: |
        One-call summary for the cashier-side card-scan UX. Returns:

        - `customer` — identity fields
        - `programCurrencies[]` — every program currency in the tenant + the
          customer's balance for each (summed across all customer_liability
          ledgers for that program)
        - `cards[]` — every stored-value card the customer owns (active +
          inactive). The POS UI shows active cards as tenderable and inactive
          ones as read-only with a warning chip.

        Replaces three otherwise-separate calls (by-barcode → wallets →
        cards). All balances in minor units.
      responses:
        '200':
          description: Customer summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PosCustomerSummary' }
        '404':
          description: Customer not found in this tenant
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /cards/customers/{customerId}/cards:
    parameters:
      - in: path
        name: customerId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Cards]
      summary: List a customer's cards
      responses:
        '200':
          description: Card list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Card' }

  /cards/{cardId}/topup:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Cards]
      summary: Top up a card
      description: Per-transaction min HK$50 POS / HK$100 app. Per-card max HK$4000.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TopupCardRequest' }
      responses:
        '200': { description: Topped up, content: { application/json: { schema: { $ref: '#/components/schemas/Card' } } } }
        '423': { description: Card frozen, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '410': { description: Card expired, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: 'Below minimum / above maximum / exceeds per-card cap', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /cards/{cardId}/transactions:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Cards]
      summary: Per-card audit log (statement)
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Statement
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CardStatement' }

  /cards/transfer:
    post:
      tags: [Cards]
      summary: Transfer balance between two cards (same account + same currency)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransferCardsRequest' }
      responses:
        '200': { description: Transferred }
        '403': { description: Cross-customer / cross-account transfer rejected, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: 'Currency mismatch or insufficient balance', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /cards/{cardId}/report-lost:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Cards]
      summary: Report card lost (flips status to frozen)
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { notes: { type: string } } }
      responses:
        '200': { description: Frozen, content: { application/json: { schema: { $ref: '#/components/schemas/Card' } } } }

  /cards/{cardId}/issue-replacement:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Cards]
      summary: Issue a replacement for a frozen card (moves balance + links provenance)
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [barcode], properties: { barcode: { type: string, minLength: 4, maxLength: 40 } } }
      responses:
        '201': { description: Replacement issued, content: { application/json: { schema: { $ref: '#/components/schemas/Card' } } } }

  # ─── Payment spend policy ─────────────────────────────────────
  /payment-spend-policies:
    get:
      tags: [Payment Spend Policy]
      summary: List policy rows for the tenant
      parameters:
        - in: query
          name: programId
          schema: { type: string, format: uuid }
          description: Filter to one program. When omitted, returns all programs.
      responses:
        '200':
          description: Policy list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/PaymentSpendPolicy' }
    post:
      tags: [Payment Spend Policy]
      summary: Create a policy row
      description: |
        sourceType=program_currency requires programCurrencyId; the other
        source types must NOT set it.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePaymentSpendPolicy' }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/PaymentSpendPolicy' } } } }

  /payment-spend-policies/{policyId}:
    parameters:
      - in: path
        name: policyId
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Payment Spend Policy]
      summary: Update mutable fields (priority, enabled, caps, allowance flags)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdatePaymentSpendPolicy' }
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/PaymentSpendPolicy' } } } }
        '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    delete:
      tags: [Payment Spend Policy]
      summary: Delete a policy row
      description: |
        Note: auto-seed may recreate stored_value_card / external_payment
        rows when a new money-denominated currency is later added.
      responses:
        '204': { description: Deleted }
        '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /pos/rewards/redeem:
    post:
      tags: [POS]
      summary: Redeem points or a reward at checkout
      description: |
        Debits the customer's ledger line item and returns a discount code /
        amount for the POS to apply to the bill before final total.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RedeemRequest' }
      responses:
        '200':
          description: Redeemed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RedeemResponse' }
        '409':
          description: Insufficient balance or tier
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ─── Ledger ───────────────────────────────────────────────────
  /ledgers/{ledgerId}:
    parameters:
      - { $ref: '#/components/parameters/LedgerId' }
    get:
      tags: [Ledger]
      summary: Get a ledger
      responses:
        '200':
          description: Ledger
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ledger' }

  /ledgers/{ledgerId}/transactions:
    parameters:
      - { $ref: '#/components/parameters/LedgerId' }
      - { $ref: '#/components/parameters/Page' }
      - { $ref: '#/components/parameters/PageSize' }
    get:
      tags: [Ledger]
      summary: List ledger transactions
      description: Human-readable narration of every ledger block — one row per block.
      responses:
        '200':
          description: Transactions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/LedgerTransaction'}

  /ledgers/{ledgerId}/audit:
    parameters:
      - { $ref: '#/components/parameters/LedgerId' }
    get:
      tags: [Ledger]
      summary: Verify the ledger's hash chain
      description: |
        Walks every block and recomputes `sha256(prev.hash || index || credit || debit || balance || created_at)`.
        Returns `ok: true` iff every block hash matches. Tamper-evidence check.
      responses:
        '200':
          description: Audit result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerAudit' }

  # ─── Wallet (customer-facing) ─────────────────────────────────
  /customers/{customerId}/wallet:
    parameters:
      - { $ref: '#/components/parameters/CustomerId' }
    get:
      tags: [Wallet]
      summary: Get a customer's wallet summary
      description: |
        Returns one wallet line item per program the customer is enrolled in,
        with current tier, balances per reward, and the next tier threshold.
      responses:
        '200':
          description: Wallet
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wallet' }

  # ─── Webhooks ─────────────────────────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      responses:
        '200':
          description: Subscriptions
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Webhook' }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
      responses:
        '201':
          description: Subscription
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }

  /webhooks/{webhookId}:
    parameters:
      - { $ref: '#/components/parameters/WebhookId' }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      responses:
        '204': { description: Deleted }

  # ─── Health ───────────────────────────────────────────────────
  /healthz:
    get:
      tags: [Health]
      summary: Liveness probe
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /readyz:
    get:
      tags: [Health]
      summary: Readiness probe
      security: []
      responses:
        '200':
          description: Ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ready }
                  db: { type: string, example: up }
                  cache: { type: string, example: up }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    Page:
      in: query
      name: page
      schema: { type: integer, minimum: 1, default: 1 }
    PageSize:
      in: query
      name: pageSize
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    StoreId:
      in: path
      name: storeId
      required: true
      schema: { type: string, format: uuid }
    CustomerId:
      in: path
      name: customerId
      required: true
      schema: { type: string, format: uuid }
    ProgramId:
      in: path
      name: programId
      required: true
      schema: { type: string, format: uuid }
    LedgerId:
      in: path
      name: ledgerId
      required: true
      schema: { type: string, format: uuid }
    OrderId:
      in: path
      name: orderId
      required: true
      schema: { type: string, format: uuid }
    WebhookId:
      in: path
      name: webhookId
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema: { type: string, format: uuid }
      description: UUIDv4. Retained for 24 h. Same key = same response, no re-apply.
    IdempotencyKeyOptional:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, format: uuid }

  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Resource already exists.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unprocessable:
      description: Request validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    # ── Common ────────────────────────────────────────────────
    Error:
      type: object
      required: [code, message]
      properties:
        code:   { type: string, example: insufficient_balance }
        message: { type: string, example: 'Customer has 40 points but tried to redeem 100.' }
        details: { type: object, additionalProperties: true }
    Paginated:
      type: object
      required: [page, pageSize, total, items]
      properties:
        page:     { type: integer, example: 1 }
        pageSize: { type: integer, example: 50 }
        total:    { type: integer, example: 137 }
        items:    { type: array, items: { type: object } }
    Money:
      type: object
      description: Integer minor units and ISO-4217 code.
      required: [amount, currency]
      properties:
        amount:   { type: integer, example: 2500 }
        currency: { type: string, example: HKD, enum: [HKD, MOP, USD, CNY, SGD, EUR, GBP, JPY] }

    # ── Auth ──────────────────────────────────────────────────
    MerchantSignupRequest:
      type: object
      required: [email, password, account, employee]
      properties:
        email:    { type: string, format: email }
        password: { type: string, format: password, minLength: 8 }
        account:
          type: object
          required: [name]
          properties:
            name: { type: string, example: Café Lapa }
        employee:
          type: object
          required: [firstName, lastName]
          properties:
            firstName: { type: string }
            lastName:  { type: string }
    MerchantSignupResponse:
      type: object
      properties:
        accessToken:  { type: string }
        refreshToken: { type: string }
        merchant:     { $ref: '#/components/schemas/Merchant' }
        store:       { $ref: '#/components/schemas/Store' }
        employee:    { $ref: '#/components/schemas/Employee' }
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:    { type: string, format: email }
        password: { type: string, format: password }
    LoginResponse:
      type: object
      properties:
        accessToken:  { type: string }
        refreshToken: { type: string }
        principal:    { $ref: '#/components/schemas/Principal' }
    PosTokenRequest:
      type: object
      required: [storeId, employeeId, pin]
      properties:
        storeId:    { type: string, format: uuid }
        employeeId: { type: string, format: uuid }
        pin:        { type: string, example: '4729' }
    PosTokenResponse:
      type: object
      properties:
        accessToken:  { type: string }
        refreshToken: { type: string }
        expiresIn:    { type: integer, example: 900 }
    Principal:
      type: object
      properties:
        kind:       { type: string, enum: [merchant_admin, employee, customer] }
        accountId:  { type: string, format: uuid }
        storeId:    { type: string, format: uuid, nullable: true }
        employeeId: { type: string, format: uuid, nullable: true }
        customerId: { type: string, format: uuid, nullable: true }

    # ── Merchant / Store / Employee ───────────────────────────
    Merchant:
      type: object
      properties:
        id:        { type: string, format: uuid }
        name:      { type: string }
        currency:  { type: string, example: HKD }
        createdAt: { type: string, format: date-time }
    MerchantUpdate:
      type: object
      properties:
        name:     { type: string }
        currency: { type: string }
    Store:
      type: object
      properties:
        id:           { type: string, format: uuid }
        name:         { type: string }
        region:       { type: string }
        address:      { type: string }
        isMainBranch: { type: boolean }
        createdAt:    { type: string, format: date-time }
    StoreCreate:
      type: object
      required: [name]
      properties:
        name:    { type: string, example: Taipa Flagship }
        region:  { type: string }
        address: { type: string }
    StoreUpdate:
      type: object
      properties:
        name:    { type: string }
        region:  { type: string }
        address: { type: string }
    Employee:
      type: object
      properties:
        id:        { type: string, format: uuid }
        firstName: { type: string }
        lastName:  { type: string }
        storeId:   { type: string, format: uuid }
        role:      { type: string, enum: [owner, manager, cashier] }
        createdAt: { type: string, format: date-time }
    EmployeeCreate:
      type: object
      required: [firstName, lastName, storeId, role]
      properties:
        firstName: { type: string }
        lastName:  { type: string }
        storeId:   { type: string, format: uuid }
        role:      { type: string, enum: [owner, manager, cashier] }
        pin:       { type: string, example: '4729' }

    # ── Customer ──────────────────────────────────────────────
    Customer:
      type: object
      properties:
        id:        { type: string, format: uuid }
        firstName: { type: string }
        lastName:  { type: string }
        handle:    { type: string, nullable: true, description: Short display name or loyalty card code. }
        email:     { type: string, format: email, nullable: true }
        phone:     { type: string, example: '+85261234567', nullable: true }
        gender:    { type: string, enum: [male, female, other, undisclosed], nullable: true }
        birthday:  { type: string, format: date, nullable: true }
        language:  { type: string, example: zh-HK, nullable: true }
        location:  { type: string, nullable: true }
        joinedAt:  { type: string, format: date-time }
        storeId:   { type: string, format: uuid, nullable: true }
    CustomerCreate:
      type: object
      required: [firstName, lastName]
      properties:
        firstName: { type: string }
        lastName:  { type: string }
        handle:    { type: string }
        email:     { type: string, format: email }
        phone:     { type: string }
        gender:    { type: string, enum: [male, female, other, undisclosed] }
        birthday:  { type: string, format: date }
        language:  { type: string }
        storeId:   { type: string, format: uuid }
    CustomerUpdate:
      type: object
      properties:
        firstName: { type: string }
        lastName:  { type: string }
        email:     { type: string, format: email }
        phone:     { type: string }
        language:  { type: string }
    CustomerLookupRequest:
      type: object
      description: Provide exactly one identifier.
      properties:
        phone:         { type: string }
        email:         { type: string, format: email }
        handle:        { type: string }
        loyaltyCardId: { type: string }
      oneOf:
        - required: [phone]
        - required: [email]
        - required: [handle]
        - required: [loyaltyCardId]
    CustomerLookupResponse:
      type: object
      properties:
        customer: { $ref: '#/components/schemas/Customer' }
        wallet:   { $ref: '#/components/schemas/Wallet' }

    # ── Catalog ───────────────────────────────────────────────
    Category:
      type: object
      properties:
        id:       { type: string, format: uuid }
        name:     { type: string }
        sequence: { type: integer }
    CategoryCreate:
      type: object
      required: [name]
      properties:
        name:     { type: string }
        sequence: { type: integer, default: 0 }
    Product:
      type: object
      properties:
        id:           { type: string, format: uuid }
        name:         { type: string }
        description:  { type: string }
        price:        { $ref: '#/components/schemas/Money' }
        categoryId:   { type: string, format: uuid, nullable: true }
        pictureUrl:   { type: string, format: uri, nullable: true }
        sequence:     { type: integer }
    ProductCreate:
      type: object
      required: [name, price]
      properties:
        name:        { type: string }
        description: { type: string }
        price:       { $ref: '#/components/schemas/Money' }
        categoryId:  { type: string, format: uuid }
        sequence:    { type: integer, default: 0 }

    # ── Loyalty program / tier / reward / rule ────────────────
    LoyaltyProgram:
      type: object
      properties:
        id:          { type: string, format: uuid }
        name:        { type: string, example: Starlit Rewards }
        description: { type: string }
        type:        { type: string, enum: [reward_with_tier, reward_without_tier, prepaid, rebate] }
        status:      { type: string, enum: [draft, active, inactive] }
        currency:    { type: string, example: HKD }
        createdAt:   { type: string, format: date-time }
    LoyaltyProgramCreate:
      type: object
      required: [name, type, tiers, tierPoint]
      properties:
        name:        { type: string }
        description: { type: string }
        type:        { type: string, enum: [reward_with_tier, reward_without_tier, prepaid, rebate] }
        currency:    { type: string, example: HKD }
        tiers:
          type: array
          items: { $ref: '#/components/schemas/TierCreate' }
        tierPoint:
          type: object
          description: Drives the auto-generated "spend N = earn 1 point" rule.
          required: [label, unitLabel, spentToPoints]
          properties:
            label:     { type: string, example: Star }
            unitLabel: { type: string, example: stars }
            willExpire: { type: boolean, default: false }
            spentToPoints:
              type: object
              required: [spentAmount, toPoints]
              properties:
                spentAmount: { type: integer, example: 2000, description: Minor units per 1 point. }
                toPoints:    { type: integer, example: 1 }
    LoyaltyProgramUpdate:
      type: object
      properties:
        name:        { type: string }
        description: { type: string }
        status:      { type: string, enum: [draft, active, inactive] }

    Tier:
      type: object
      properties:
        id:             { type: string, format: uuid }
        name:           { type: string, example: Silver }
        sequence:       { type: integer, example: 1 }
        pointsRequired: { type: integer, example: 100 }
    TierCreate:
      type: object
      required: [name, sequence, pointsRequired]
      properties:
        name:           { type: string }
        sequence:       { type: integer }
        pointsRequired: { type: integer }

    Reward:
      type: object
      properties:
        id:             { type: string, format: uuid }
        name:           { type: string }
        description:    { type: string }
        rewardType:     { type: string, enum: [tier_points, currency, stamp, cashback, item] }
        isEssential:    { type: boolean, description: Pre-seeded on enrollment. }
        canBeRedeemed:  { type: boolean }
        expiresAfterDays: { type: integer, nullable: true }
    RewardCreate:
      type: object
      required: [name, rewardType]
      properties:
        name:             { type: string }
        description:      { type: string }
        rewardType:       { type: string, enum: [tier_points, currency, stamp, cashback, item] }
        isEssential:      { type: boolean, default: false }
        canBeRedeemed:    { type: boolean, default: true }
        expiresAfterDays: { type: integer, nullable: true }

    Rule:
      type: object
      properties:
        id:         { type: string, format: uuid }
        name:       { type: string }
        type:       { type: string, enum: [reward, tier] }
        status:     { type: string, enum: [active, inactive] }
        startDate:  { type: string, format: date-time, nullable: true }
        endDate:    { type: string, format: date-time, nullable: true }
        conditions: { type: object, description: json-rules-engine conditions. }
        event:      { type: object, description: json-rules-engine event with loyalty action. }
        version:    { type: integer, example: 1 }
    RuleCreate:
      type: object
      required: [name, type, conditions, event]
      properties:
        name:       { type: string, example: Promote to Silver }
        type:       { type: string, enum: [reward, tier] }
        conditions: { type: object, example: { all: [{ fact: customer-tier-points-fact, operator: greaterThanInclusive, value: 100 }]}}
        event:      { type: object, example: { type: actions, params: [{ actionType: TIER_UP, tierId: 9f27e1a4-... }]}}
        startDate:  { type: string, format: date-time }
        endDate:    { type: string, format: date-time }

    # ── Enrollment / Wallet / Ledger ──────────────────────────
    EnrollmentRequest:
      type: object
      required: [programId, customerId]
      properties:
        programId:  { type: string, format: uuid }
        customerId: { type: string, format: uuid }
    Enrollment:
      type: object
      properties:
        ledger:     { $ref: '#/components/schemas/Ledger' }
        walletLineItemId: { type: string, format: uuid }
        createdAt:  { type: string, format: date-time }

    Wallet:
      type: object
      properties:
        customerId: { type: string, format: uuid }
        lineItems:
          type: array
          items:
            type: object
            properties:
              programId:   { type: string, format: uuid }
              programName: { type: string }
              ledgerId:    { type: string, format: uuid }
              currentTier:
                type: object
                properties:
                  tierId:         { type: string, format: uuid }
                  name:           { type: string, example: Silver }
                  pointsRequired: { type: integer }
              nextTier:
                type: object
                nullable: true
                properties:
                  tierId:         { type: string, format: uuid }
                  name:           { type: string, example: Gold }
                  pointsRequired: { type: integer }
                  pointsToGo:     { type: integer, example: 42 }
              balances:
                type: array
                items:
                  type: object
                  properties:
                    rewardId: { type: string, format: uuid }
                    name:     { type: string, example: Stars }
                    balance:  { type: integer, example: 58 }
                    unit:     { type: string, example: stars }

    Ledger:
      type: object
      properties:
        id:         { type: string, format: uuid }
        programId:  { type: string, format: uuid }
        customerId: { type: string, format: uuid }
        currentTierId: { type: string, format: uuid, nullable: true }
        createdAt:  { type: string, format: date-time }
    LedgerTransaction:
      type: object
      properties:
        id:            { type: string, format: uuid }
        blockId:       { type: string, format: uuid }
        rewardId:      { type: string, format: uuid }
        rewardName:    { type: string }
        balanceChange: { type: integer, example: 5 }
        balanceAfter:  { type: integer, example: 58 }
        comment:       { type: string, example: 'Granted 5 stars (order INV-00219)' }
        sourceType:    { type: string, enum: [purchase, adjustment, redemption, expiry, tier_up, void] }
        sourceId:      { type: string, format: uuid, nullable: true }
        createdAt:     { type: string, format: date-time }
    LedgerAudit:
      type: object
      properties:
        ok:             { type: boolean, example: true }
        blocksChecked:  { type: integer, example: 842 }
        firstMismatch:  { type: string, nullable: true, description: Block id of first mismatch if ok=false. }

    # ── POS hot path ──────────────────────────────────────────
    OrderPreviewRequest:
      type: object
      required: [storeId, lineItems, subtotal]
      properties:
        storeId:    { type: string, format: uuid }
        employeeId: { type: string, format: uuid }
        customerId: { type: string, format: uuid, nullable: true, description: Null for anonymous carts. }
        lineItems:
          type: array
          items: { $ref: '#/components/schemas/OrderLineItemInput' }
        subtotal:   { $ref: '#/components/schemas/Money' }
        discount:   { $ref: '#/components/schemas/Money' }
        orderedAt:  { type: string, format: date-time }
        redemptions:
          type: array
          description: |
            Each entry redeems EITHER a coupon (issuedRewardId) OR debits a
            program currency wallet (programCurrencyId + quantity). Mutually
            exclusive per entry; runtime enforces.
          items: { $ref: '#/components/schemas/RedemptionInput' }
        cardPayments:
          type: array
          description: |
            Stored-value card debits applied to this order. Preview accepts
            them too — engine facts read them so card-payment bonus rules
            project correctly. Preview does NOT debit the card.
          items: { $ref: '#/components/schemas/CardPaymentInput' }
    OrderPreviewResponse:
      type: object
      properties:
        total:       { $ref: '#/components/schemas/Money' }
        perProgram:
          type: array
          items:
            type: object
            properties:
              programId:    { type: string, format: uuid }
              programName:  { type: string }
              earns:
                type: array
                items:
                  type: object
                  properties:
                    rewardId:    { type: string, format: uuid }
                    rewardName:  { type: string }
                    quantity:    { type: integer, example: 5 }
                    reason:      { type: string, example: '2000 HKD spent at 1 star per 400 HKD' }
              redeemOptions:
                type: array
                items:
                  type: object
                  properties:
                    rewardId:   { type: string, format: uuid }
                    name:       { type: string }
                    balance:    { type: integer }
                    unitValue:  { $ref: '#/components/schemas/Money' }
              tierChange:
                type: object
                nullable: true
                properties:
                  fromTierId: { type: string, format: uuid }
                  toTierId:   { type: string, format: uuid }
                  toName:     { type: string }
        warnings:
          type: array
          items: { type: string, example: Rule evaluation timed out — engaging default. }

    OrderApplyRequest:
      allOf:
        - $ref: '#/components/schemas/OrderPreviewRequest'
        - type: object
          required: [total]
          properties:
            externalOrderId: { type: string, example: INV-00219, description: POS-side order id (optional). }
            total:           { $ref: '#/components/schemas/Money' }

    RedemptionInput:
      type: object
      description: |
        EITHER (issuedRewardId) for a coupon OR (programCurrencyId + quantity)
        for a wallet debit. Runtime rejects entries with both or neither.
      properties:
        issuedRewardId:    { type: string, format: uuid, description: Coupon redemption. }
        programCurrencyId: { type: string, format: uuid, description: Loyalty currency to debit. }
        quantity:          { type: integer, minimum: 1 }
        rewardId:          { type: string, format: uuid, description: Optional catalog source for wallet-debit redemptions. }

    CardPaymentInput:
      type: object
      required: [cardId, amountMinor]
      properties:
        cardId:      { type: string, format: uuid }
        amountMinor: { type: integer, minimum: 1, description: Debit amount in minor units (e.g. cents). }

    CardPaymentResult:
      type: object
      properties:
        cardId:      { type: string, format: uuid }
        amountMinor: { type: integer, description: Amount debited (positive integer). }
        newBalance:  { type: integer, description: Card balance after the debit. }

    CardRefundResult:
      type: object
      properties:
        cardId:      { type: string, format: uuid }
        amountMinor: { type: integer, description: Amount refunded (positive integer). }
        newBalance:  { type: integer, description: Card balance after the refund credit. }

    CardRefundPending:
      type: object
      properties:
        cardId:      { type: string, format: uuid }
        amountMinor: { type: integer, description: Amount owed to the customer; not yet credited. }
        reason:      { type: string, example: card_frozen, description: Why the in-transaction refund couldn't run. }

    PaymentSummary:
      type: object
      properties:
        total:         { type: integer, description: Order total (minor units). }
        cardTotal:     { type: integer, description: 'Sum of cardPayments amountMinor values.' }
        externalTotal: { type: integer, description: 'Implied cash/EFTPOS remainder: total minus cardTotal.' }
        currency:      { type: string, example: HKD }

    OrderApplyResponse:
      type: object
      properties:
        orderId:    { type: string, format: uuid }
        appliedAt:  { type: string, format: date-time }
        replay:     { type: boolean, description: True when this response was returned via idempotency replay. }
        results:
          type: array
          items:
            type: object
            properties:
              programId:    { type: string, format: uuid }
              programName:  { type: string }
              tierSetId:    { type: string, format: uuid, nullable: true }
              rewardsGranted:
                type: array
                items:
                  type: object
                  properties:
                    programCurrencyId: { type: string, format: uuid }
                    quantity:          { type: integer }
              tierChanges:
                type: array
                items:
                  type: object
                  properties:
                    tierId: { type: string, format: uuid }
                    ruleId: { type: string, format: uuid }
              redemptions:
                type: array
                items:
                  type: object
                  properties:
                    programCurrencyId: { type: string, format: uuid }
                    quantity:          { type: integer }
                    newBalance:        { type: integer }
                    issuedRewardId:    { type: string, format: uuid }
                    rewardId:          { type: string, format: uuid }
                    discountMinor:     { type: integer }
              matchedRules:
                type: array
                items:
                  type: object
                  properties:
                    ruleId:   { type: string, format: uuid }
                    ruleName: { type: string }
              warnings:
                type: array
                items: { type: string, example: Rule evaluation timed out. }
        cardPayments:
          type: array
          description: One entry per debited card. Empty when the order had no cardPayments[].
          items: { $ref: '#/components/schemas/CardPaymentResult' }
        paymentSummary: { $ref: '#/components/schemas/PaymentSummary' }

    OrderVoidRequest:
      type: object
      properties:
        reason: { type: string, example: customer_refund }

    OrderVoidResponse:
      type: object
      properties:
        orderId:        { type: string, format: uuid }
        voidedAt:       { type: string, format: date-time }
        reversedBlocks: { type: integer, description: Count of ledger blocks reversed. }
        cardRefunds:
          type: array
          description: Active cards whose balance was restored in-transaction.
          items: { $ref: '#/components/schemas/CardRefundResult' }
        cardRefundsPending:
          type: array
          description: |
            Inactive cards (frozen / expired / closed) that were paid with at
            apply time. The merchant must reconcile out-of-band. Each pending
            refund also emits a `card.refund_pending` webhook.
          items: { $ref: '#/components/schemas/CardRefundPending' }

    OrderLineItemInput:
      type: object
      required: [productId, quantity, subTotal]
      properties:
        productId: { type: string, format: uuid }
        quantity:  { type: integer, example: 2 }
        subTotal:  { $ref: '#/components/schemas/Money' }

    RedeemRequest:
      type: object
      required: [customerId, programId, rewardId, quantity]
      properties:
        customerId: { type: string, format: uuid }
        programId:  { type: string, format: uuid }
        rewardId:   { type: string, format: uuid }
        quantity:   { type: integer, example: 100 }
        orderId:    { type: string, format: uuid, description: Apply the discount to an in-flight order. }
    RedeemResponse:
      type: object
      properties:
        discount:       { $ref: '#/components/schemas/Money' }
        ledgerBlockId:  { type: string, format: uuid }
        newBalance:     { type: integer, example: 0 }

    # ── Webhooks ──────────────────────────────────────────────
    Webhook:
      type: object
      properties:
        id:        { type: string, format: uuid }
        url:       { type: string, format: uri }
        events:
          type: array
          items: { type: string, example: order.applied }
        secret:    { type: string, description: Used to HMAC-sign outgoing payloads. }
        createdAt: { type: string, format: date-time }
    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url:    { type: string, format: uri }
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - order.applied
              - order.voided
              - rewards.granted
              - tier.changed
              - customer.enrolled
              - rule.triggered
              - card.payment
              - card.refund
              - card.refund_pending
              - card.topup
              - coupon.claimed
              - coupon.redeemed
              - coupon.pool_exhausted

    # ── POS aggregator ─────────────────────────────────────────
    PosCustomerSummary:
      type: object
      properties:
        customer:
          type: object
          properties:
            id:        { type: string, format: uuid }
            firstName: { type: string, nullable: true }
            lastName:  { type: string, nullable: true }
            email:     { type: string, nullable: true }
            handle:    { type: string, nullable: true }
            phone:     { type: string, nullable: true }
            birthday:  { type: string, nullable: true }
            language:  { type: string, nullable: true }
        programCurrencies:
          type: array
          items:
            type: object
            properties:
              id:                { type: string, format: uuid }
              programId:         { type: string, format: uuid, nullable: true }
              code:              { type: string }
              name:              { type: string }
              unitLabel:         { type: string }
              role:              { type: string, enum: [redeemable_points, status_counter, stamp, promo_credit, cashback_credit] }
              precision:         { type: integer }
              moneyCurrencyCode: { type: string, nullable: true }
              balanceMinor:      { type: integer, description: "Customer's balance in this currency (minor units). Summed across all customer_liability ledgers." }
        cards:
          type: array
          items: { $ref: '#/components/schemas/Card' }

    # ── Cards ──────────────────────────────────────────────────
    Card:
      type: object
      properties:
        id:               { type: string, format: uuid }
        accountId:        { type: string, format: uuid }
        customerId:       { type: string, format: uuid, nullable: true }
        barcode:          { type: string, example: STAR-2026-001 }
        currency:         { type: string, enum: [HKD, MOP] }
        balanceMinor:     { type: integer, description: Balance in minor units. }
        status:           { type: string, enum: [active, frozen, expired, closed] }
        lastTxnAt:        { type: string, format: date-time }
        replacesCardId:   { type: string, format: uuid, nullable: true }
        replacedByCardId: { type: string, format: uuid, nullable: true }
        createdAt:        { type: string, format: date-time }

    RegisterCardRequest:
      type: object
      required: [customerId, barcode, currency]
      properties:
        customerId:          { type: string, format: uuid }
        barcode:             { type: string, minLength: 4, maxLength: 40 }
        currency:            { type: string, enum: [HKD, MOP] }
        initialBalanceMinor: { type: integer, minimum: 0, default: 0 }

    TopupCardRequest:
      type: object
      required: [amountMinor, channel]
      properties:
        amountMinor: { type: integer, minimum: 1, maximum: 400000 }
        channel:     { type: string, enum: [pos, app] }

    TransferCardsRequest:
      type: object
      required: [fromCardId, toCardId, amountMinor]
      properties:
        fromCardId:  { type: string, format: uuid }
        toCardId:    { type: string, format: uuid }
        amountMinor: { type: integer, minimum: 1 }
        reason:      { type: string, maxLength: 200 }

    CardTransaction:
      type: object
      properties:
        id:                   { type: string, format: uuid }
        kind:                 { type: string, enum: [register, topup, payment, refund] }
        amountMinor:          { type: integer, description: 'Signed minor units: positive credit, negative debit.' }
        balanceAfterMinor:    { type: integer }
        orderId:              { type: string, format: uuid, nullable: true }
        relatedTransactionId: { type: string, format: uuid, nullable: true, description: 'Refund rows point at the originating payment.' }
        createdAt:            { type: string, format: date-time }

    CardStatement:
      type: object
      properties:
        cardId:       { type: string, format: uuid }
        customerId:   { type: string, format: uuid, nullable: true }
        balanceMinor: { type: integer }
        items:
          type: array
          items: { $ref: '#/components/schemas/CardTransaction' }

    # ── Payment spend policy ───────────────────────────────────
    PaymentSourceType:
      type: string
      enum: [program_currency, stored_value_card, external_payment]

    PaymentSpendPolicy:
      type: object
      properties:
        id:                    { type: string, format: uuid }
        programId:             { type: string, format: uuid, nullable: true }
        sourceType:            { $ref: '#/components/schemas/PaymentSourceType' }
        programCurrency:
          type: object
          nullable: true
          description: Set only when sourceType=program_currency.
          properties:
            id:        { type: string, format: uuid }
            code:      { type: string }
            name:      { type: string }
            role:      { type: string, enum: [redeemable_points, status_counter, stamp, promo_credit, cashback_credit] }
            unitLabel: { type: string }
        priority:             { type: integer, minimum: 0, maximum: 999, description: Lower = consumed first at POS. }
        enabled:              { type: boolean }
        maxPctOfOrderTotal:   { type: integer, minimum: 0, maximum: 100, nullable: true }
        maxAmountMinor:       { type: integer, minimum: 0, nullable: true }
        allowedWithCoupons:   { type: boolean }
        allowedWithDiscounts: { type: boolean }
        createdAt:            { type: string, format: date-time }

    CreatePaymentSpendPolicy:
      type: object
      required: [programId, sourceType, priority]
      properties:
        programId:            { type: string, format: uuid }
        sourceType:           { $ref: '#/components/schemas/PaymentSourceType' }
        programCurrencyId:    { type: string, format: uuid, nullable: true, description: Required iff sourceType=program_currency. }
        priority:             { type: integer, minimum: 0, maximum: 999 }
        enabled:              { type: boolean, default: true }
        maxPctOfOrderTotal:   { type: integer, minimum: 0, maximum: 100, nullable: true }
        maxAmountMinor:       { type: integer, minimum: 0, nullable: true }
        allowedWithCoupons:   { type: boolean, default: true }
        allowedWithDiscounts: { type: boolean, default: true }

    UpdatePaymentSpendPolicy:
      type: object
      description: |
        Only mutable fields. sourceType and programCurrencyId cannot be
        repointed (would invalidate downstream POS resolution).
      properties:
        priority:             { type: integer, minimum: 0, maximum: 999 }
        enabled:              { type: boolean }
        maxPctOfOrderTotal:   { type: integer, minimum: 0, maximum: 100, nullable: true }
        maxAmountMinor:       { type: integer, minimum: 0, nullable: true }
        allowedWithCoupons:   { type: boolean }
        allowedWithDiscounts: { type: boolean }
