openapi: 3.0.3

info:
  version: v1
  title: DeliveryVip Loyalty Operator Contract
  description: >-
    The HTTP contract a loyalty operator must implement to serve the DeliveryVip loyalty
    extension. DeliveryVip is the ordering application: it CALLS these endpoints on the
    api_base_url declared by the operator's app (loyalty extension, dv.loyalty scope).
    Every request is signed with HMAC-SHA256 of the RAW body (empty string for body-less
    requests) using the app's OAuth secret — verify the signature against the exact bytes
    received, never a re-serialization. Respond fast: DeliveryVip uses 3s connect / 5s read
    timeouts, and 5 transport failures (timeout/5xx) within 5 minutes open a circuit breaker
    for the whole extension. Business answers (409/422/403/404) never count as failures.
    Configuration is pushed the other way (operator -> DeliveryVip) via
    PUT/DELETE /merchant/v3/{merchantId}/loyalty/config — see the Merchant API swagger;
    the PUT triggers a synchronous signed GET /v1/loyalty/ping here before anything is stored.
    That push is also where an operator declares its commercial boundaries per order
    (capabilities.redemptionLimits: an optional min and max, each fixed BRL or percent of the
    redeemable base). DeliveryVip enforces them before calling this API, so the amount below
    always already respects whatever the operator declared.
    Canonical byte-pinned request/response examples live in the platform repository under
    test/fixtures/files/loyalty/.

servers:
  - url: "{api_base_url}"
    description: The base URL the operator declares on the loyalty extension (https only; may include a path prefix).
    variables:
      api_base_url:
        default: https://operator.example.com/dv/v1

security:
  - appId: []
    appMerchantId: []
    appSignature: []

tags:
  - name: Handshake
    description: Synchronous verification during the config push.
  - name: Accounts
    description: Customer identity and balance. The account id must be opaque and stable across relinks.
  - name: Redemptions
    description: Synchronous balance debit (checkout) and its explicit idempotent reversal.

paths:
  /v1/loyalty/ping:
    get:
      tags:
        - Handshake
      summary: Signed reachability check
      description: >-
        Called synchronously while the operator pushes a merchant configuration. Verify the
        signature (HMAC of an empty string) and answer 2xx. Any non-2xx, timeout or invalid
        signature makes DeliveryVip reject the configuration push with 422 — nothing is stored.
      responses:
        "204":
          description: Operator reachable and signature valid.

  /v1/loyalty/accounts:link:
    post:
      tags:
        - Accounts
      summary: Locate-or-enroll the customer (opt-in)
      description: >-
        Sent when the customer opts into the program at checkout. The click IS the consent
        (consent.grantedAt is the moment of the click; timestamps carry the platform's UTC offset).
        The customer profile is attested by DeliveryVip (non-guest, complete profile).
        The operator may answer with a PIN challenge instead of an account: DeliveryVip then
        re-sends the same body plus challenge.answer typed by the customer. The PIN is validated
        exclusively on the operator side (rate limiting included) and is never stored by DeliveryVip.
        Answering with both id and challenge in one response is forbidden (DeliveryVip treats
        challenge as authoritative and discards the id).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/linkRequest"
      responses:
        "200":
          description: Account located/enrolled, or a challenge is required first.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/linkedAccount"
                  - $ref: "#/components/schemas/challenge"
        "403":
          description: >-
            Challenge rejected (wrong PIN). The body must be generic: it must not reveal
            whether the account exists.
        "404":
          description: Enrollment closed — the customer must sign up at the store first.

  /v1/loyalty/accounts/{accountId}:
    get:
      tags:
        - Accounts
      summary: Account balance
      description: >-
        Balance read for the checkout surface. DeliveryVip caches the answer for 60 seconds
        per account. The request carries only the opaque account id — never the CPF.
        Unknown fields in the response are ignored (emitting extras such as expiresAt or
        metadata is safe). The optional redemptionLimits object pins per-customer boundaries
        that override the program's for this customer — it rides the same 60-second cache as
        the balance, so a change to a customer's rules takes up to a minute to reach checkout.
      parameters:
        - in: path
          name: accountId
          required: true
          schema:
            type: string
          description: The opaque account id returned by accounts:link.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/account"
        "404":
          description: >-
            Account no longer exists on the operator side. DeliveryVip treats the customer
            as not linked anymore and offers the opt-in again (never counted as an outage).

  /v1/loyalty/redemptions:
    post:
      tags:
        - Redemptions
      summary: Synchronous balance debit
      description: >-
        The authoritative debit, called as the LAST step inside DeliveryVip's order-creation
        transaction. A non-201 answer (or timeout) rolls the whole order back — the order is
        never born and the customer keeps shopping. Idempotency key is relatedOrderId (the
        order UUID): replaying the same relatedOrderId with the same amount must answer 201
        with the SAME redemptionId; the same relatedOrderId with a DIFFERENT amount must be
        rejected with 422 code idempotency_conflict. amount is a JSON number (natural
        serialization, e.g. 25.0) and is always the value actually discounted on the order —
        it may be lower than what the customer originally reserved if the cart shrank. It also
        already honours the operator's own capabilities.redemptionLimits: never below the
        declared minimum, never above the declared maximum. Percentage ceilings are computed
        over the order's items (subtotal minus other cart discounts) — shipping and fees are
        never redeemable — so for operators whose own rule applies to the order total this
        amount can be lower than their ceiling, never higher.
        A 201 without redemptionId is treated as malformed and rolls the order back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/redemptionRequest"
      responses:
        "201":
          description: Balance debited.
          content:
            application/json:
              schema:
                type: object
                required:
                  - redemptionId
                properties:
                  redemptionId:
                    type: string
                    description: The operator's identifier for this debit (needed for reversal).
        "409":
          description: Insufficient balance. The order is rolled back and the customer keeps shopping.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/errorWithCode"
        "422":
          description: >-
            Business rejection (e.g. below_minimum_order, idempotency_conflict). The order is
            rolled back with a generic message to the customer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/errorWithCode"

  /v1/loyalty/redemptions/{redemptionId}/reversal:
    post:
      tags:
        - Redemptions
      summary: Explicit idempotent reversal
      description: >-
        Undoes a debit after order cancellation, rejection, payment expiry or payment failure.
        Fire-and-forget from DeliveryVip's perspective (asynchronous with retries). Must be
        idempotent: reversing an already-reversed redemption answers 2xx again. 404 means the
        redemption is unknown — DeliveryVip stops retrying (terminal with an internal alert).
        Transport failures and 5xx are retried up to 5 times, then flagged for manual
        reconciliation. The request has no body (signature = HMAC of the empty string).
      parameters:
        - in: path
          name: redemptionId
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Reversed (or already reversed).
        "404":
          description: Redemption unknown to the operator. Terminal — never retried.

components:
  securitySchemes:
    appId:
      type: apiKey
      in: header
      name: X-App-Id
      description: The uid of the operator's OAuth application on DeliveryVip.
    appMerchantId:
      type: apiKey
      in: header
      name: X-App-MerchantId
      description: The UUID of the merchant (company) the call concerns.
    appSignature:
      type: apiKey
      in: header
      name: X-App-Signature
      description: >-
        Hex HMAC-SHA256 of the raw request body (empty string when there is no body),
        keyed with the app's OAuth secret. Verify against the exact bytes received.
  schemas:
    linkRequest:
      type: object
      required:
        - programId
        - customer
        - consent
        - enroll
      properties:
        programId:
          type: string
          description: The operator-side program identifier (as pushed in the merchant configuration).
        customer:
          type: object
          required:
            - document
            - phone
            - email
            - name
            - birthDate
          properties:
            document:
              type: string
              description: CPF, digits only.
            phone:
              type: string
              description: E.164 (e.g. +5511999998888).
            email:
              type: string
              format: email
            name:
              type: string
            birthDate:
              type: string
              format: date
        consent:
          type: object
          required:
            - purpose
            - status
            - grantedAt
          properties:
            purpose:
              type: string
              enum:
                - loyalty_program
            status:
              type: string
              enum:
                - granted
            grantedAt:
              type: string
              format: date-time
        enroll:
          type: boolean
          description: Whether the program accepts enrollment through checkout (mirrors the pushed capability).
        challenge:
          type: object
          description: Present only on the re-send after a PIN challenge.
          required:
            - type
            - answer
          properties:
            type:
              type: string
              enum:
                - pin
            answer:
              type: string

    challenge:
      type: object
      required:
        - challenge
      properties:
        challenge:
          type: object
          required:
            - type
          properties:
            type:
              type: string
              enum:
                - pin

    linkedAccount:
      type: object
      description: >-
        The account located or enrolled by accounts:link. The balance is not part of this
        answer — DeliveryVip reads it from the balance endpoint.
      required:
        - id
        - programId
        - balanceType
      properties:
        id:
          type: string
          description: Opaque and STABLE — relinking the same customer must return the same id byte-for-byte.
        programId:
          type: string
        balanceType:
          type: string
          enum:
            - cashback
    account:
      type: object
      required:
        - id
        - programId
        - balanceType
        - balance
        - currency
      properties:
        id:
          type: string
          description: Opaque and STABLE — relinking the same customer must return the same id byte-for-byte.
        programId:
          type: string
        balanceType:
          type: string
          enum:
            - cashback
        balance:
          type: number
          description: Current balance in BRL. Read format-agnostically by DeliveryVip.
        currency:
          type: string
          default: BRL
        redemptionLimits:
          allOf:
            - $ref: "#/components/schemas/redemptionLimits"
          description: >-
            OPTIONAL per-customer boundaries. When present they REPLACE the program-level
            capabilities.redemptionLimits for this customer entirely — not field by field:
            declaring only max here means this customer has no minimum, whatever the program
            declares. Omit the object (or omit a bound inside it) to leave the customer under
            the program's own limits. Answer here ONLY when your rules genuinely vary per
            customer (a tier, a segment, an individual agreement) — a single rule for every
            customer belongs on the program's capabilities, not repeated on every account.
            Note that a limit reaching you inside a per-customer lookup is not by itself
            evidence that the limit is per-customer. Malformed values are ignored and the
            program's limits stand.

    redemptionLimit:
      type: object
      description: >-
        A single boundary: a fixed amount in BRL or a percentage of the redeemable base. A
        minimum never raises the redeemed amount — it only decides whether redeeming is
        possible at all. Same shape the operator uses to declare program-level limits in
        capabilities.redemptionLimits (see the Merchant API swagger).
      required:
        - type
        - value
      properties:
        type:
          type: string
          enum:
            - fixed
            - percent
        value:
          type: number
          description: Amount in BRL when type is fixed; 0-100 when type is percent.

    redemptionLimits:
      type: object
      description: >-
        Optional commercial boundaries per order. Omit the object, or either bound, to declare
        no limit there — omit it, never send null. Percentages apply to the redeemable base:
        the order's items minus other cart discounts. Shipping and fees are never redeemable,
        so a percentage ceiling is computed over items, not over the order total.
      properties:
        min:
          $ref: "#/components/schemas/redemptionLimit"
        max:
          $ref: "#/components/schemas/redemptionLimit"

    redemptionRequest:
      type: object
      required:
        - accountId
        - relatedOrderId
        - kind
        - amount
        - order
      properties:
        accountId:
          type: string
        relatedOrderId:
          type: string
          format: uuid
          description: The DeliveryVip order UUID — the idempotency key (one debit per order).
        kind:
          type: string
          enum:
            - balance
        amount:
          type: number
          description: Value to debit, up to 2 decimal places, as a JSON number.
        order:
          type: object
          required:
            - subtotal
            - total
            - items
          properties:
            subtotal:
              type: number
            total:
              type: number
            items:
              type: array
              items:
                type: object
                required:
                  - name
                  - quantity
                  - totalPrice
                properties:
                  name:
                    type: string
                  externalCode:
                    type: string
                    description: The merchant's PDV/ERP code for the item; omitted when the product has none.
                  quantity:
                    type: integer
                  totalPrice:
                    type: number

    errorWithCode:
      type: object
      properties:
        code:
          type: string
          description: Machine-readable reason (e.g. insufficient_balance, below_minimum_order, idempotency_conflict).
