openapi: 3.0.3
info:
  title: Bayar Partner API
  version: 1.0.0
  description: |
    Partner-facing REST API for the Bayar payment orchestrator. These are the endpoints the Partner Dashboard consumes, authenticated with a partner API key. This spec is hand-authored to describe the existing handlers in `internal/api` and `cmd/bayar/serve.go`; keep it in sync when those change.
  contact:
    name: Bayar Partner Engineering
    email: partners@bayar.example
  license:
    name: Proprietary
    url: https://bayar.example/legal/api-license
servers:
  - url: https://api.beever.xyz/v1
    description: Production
  - url: https://sandbox.beever.xyz/v1
    description: Sandbox — simulated PSPs, no real money
  - url: /v1
    description: Relative, for a client that reaches Bayar through its own reverse proxy (this is how the Partner Dashboard's BFF addresses it).
security:
  - apiKey: []
tags:
  - name: Account
    description: |
      The authenticated partner: who the calling key belongs to, and the account-level settings that shape every other response.
  - name: Payments
    description: Create, list, and inspect collections and payouts.
  - name: Balances
    description: Per-currency held/reserved/available balances.
  - name: Settlements
    description: Payout settlements (a filtered view of payments).
  - name: Beneficiaries
    description: Payout destinations (bank accounts, wallets, cards, and similar).
  - name: Disputes
    description: Chargebacks, returns, inquiries, and retrievals against payments.
paths:
  /me:
    get:
      tags:
        - Account
      operationId: getMe
      summary: The authenticated partner's own profile
      responses:
        "200":
          description: Partner profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Partner"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /payments:
    get:
      tags:
        - Payments
      operationId: listPayments
      summary: List payments (cursor-paginated)
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - in: query
          name: cursor
          schema:
            type: string
          description: Opaque cursor from a prior response's next_cursor
        - in: query
          name: state
          schema:
            type: string
        - in: query
          name: currency
          schema:
            type: string
        - in: query
          name: method
          schema:
            type: string
        - in: query
          name: direction
          schema:
            $ref: "#/components/schemas/Direction"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: A page of payments
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListPaymentsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Payments
      operationId: createPayment
      summary: Create a payment (collection or payout)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePaymentRequest"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Payment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
  /payments/{id}:
    get:
      tags:
        - Payments
      operationId: getPayment
      summary: Get a payment by id (or ?merchant_reference=)
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
        - in: query
          name: merchant_reference
          schema:
            type: string
      responses:
        "200":
          description: The payment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Payment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /payments/{id}/timeline:
    get:
      tags:
        - Payments
      operationId: getPaymentTimeline
      summary: A payment's ordered state-transition history
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The payment's transitions, chronological
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentTimelineResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /payments/export:
    get:
      tags:
        - Payments
      operationId: exportPayments
      summary: Export payments as CSV
      parameters:
        - in: query
          name: format
          required: true
          schema:
            type: string
            enum:
              - csv
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - in: query
          name: state
          schema:
            type: string
        - in: query
          name: direction
          schema:
            $ref: "#/components/schemas/Direction"
      responses:
        "200":
          description: CSV stream
          content:
            text/csv:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: The window would export more than 10,000 rows. Narrow it via from/to. Code `export_too_large`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /payments/{id}/next_action:
    get:
      tags:
        - Payments
      operationId: getPaymentNextAction
      summary: The current next_action for an in-flight payment (pull-on-demand)
      description: Returns the payment's live next action, re-fetching a fresh one from the PSP when the stored action is missing or close to expiry (e.g. a DuitNow QR, which rotates about every minute). Poll this near expiry rather than caching the action.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The current next action
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Payment not found (`not_found`), or the payment has no next action — e.g. it is terminal (`no_next_action`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: The PSP could not be reached to refresh the action. Code `psp_error`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /balances:
    get:
      tags:
        - Balances
      operationId: getBalances
      summary: Partner balances per currency (held / reserved / available)
      responses:
        "200":
          description: Balances
          content:
            application/json:
              schema:
                type: object
                required:
                  - balances
                properties:
                  balances:
                    type: array
                    items:
                      $ref: "#/components/schemas/Balance"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /settlements:
    get:
      tags:
        - Settlements
      operationId: listSettlements
      summary: List settlements (alias for payments with direction=payout)
      description: Delegates to listPayments with direction forced to `payout`; it therefore accepts the same filters. A caller-supplied `direction` is ignored, so it is not declared.
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: state
          schema:
            type: string
        - in: query
          name: currency
          schema:
            type: string
        - in: query
          name: method
          schema:
            type: string
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: A page of settlement payments
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListPaymentsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /disputes:
    get:
      tags:
        - Disputes
      operationId: listDisputes
      summary: List disputes (chargebacks, returns, inquiries, retrievals)
      description: |
        Partner-scoped, read-only. **Bayar has no dispute ingestion path yet**: no PSP adapter reports chargebacks and no engine transition produces a dispute, so this endpoint returns an empty list for every partner today. An empty `data` array means "Bayar has ingested no disputes" — it does NOT mean "no chargebacks occurred against your payments". Do not present it to an end user as an authoritative chargeback record until ingestion ships.
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: state
          schema:
            type: string
            enum:
              - received
              - evidence_required
              - under_review
              - won
              - lost
              - accepted
              - expired
        - in: query
          name: kind
          schema:
            type: string
            enum:
              - chargeback
              - return
              - inquiry
              - retrieval
        - in: query
          name: payment_id
          schema:
            type: string
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: A page of disputes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListDisputesResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /disputes/{id}:
    get:
      tags:
        - Disputes
      operationId: getDispute
      summary: Get a dispute, including its evidence blob
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The dispute
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Dispute"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /beneficiaries:
    get:
      tags:
        - Beneficiaries
      operationId: listBeneficiaries
      summary: List beneficiaries
      responses:
        "200":
          description: Beneficiaries
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Beneficiary"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Beneficiaries
      operationId: createBeneficiary
      summary: Create a beneficiary
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBeneficiaryRequest"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Beneficiary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
  /beneficiaries/{id}:
    put:
      tags:
        - Beneficiaries
      operationId: updateBeneficiary
      summary: Update a beneficiary
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateBeneficiaryRequest"
      responses:
        "204":
          description: Updated
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      tags:
        - Beneficiaries
      operationId: deleteBeneficiary
      summary: Delete a beneficiary
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
components:
  parameters:
    From:
      in: query
      name: from
      description: "Inclusive lower bound on `created_at`. Two forms are accepted: an RFC3339 timestamp (`2026-07-01T09:30:00Z`), or a UTC calendar date (`2026-07-01`), read as the START of that UTC day. Any other value is a 400 — a malformed bound is never ignored, because a silently dropped filter would return the partner's entire history under the guise of a narrow window."
      schema:
        type: string
      example: 2026-07-01T00:00:00Z
    To:
      in: query
      name: to
      description: Inclusive upper bound on `created_at`. An RFC3339 timestamp, or a UTC calendar date read as the END of that UTC day (`2026-07-31` -> `23:59:59.999999Z`). Any other value is a 400.
      schema:
        type: string
      example: 2026-07-31
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      description: Caller-generated key that makes a mutation safely retryable. Every mutating operation honours it. Replaying the same key with the same body returns the first response verbatim; the same key with a DIFFERENT body is a 409 (`idempotency_conflict`). Keys expire after 24h. See ADR 0006.
      schema:
        type: string
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "Partner API key, sent as `Authorization: Bearer <token>`."
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: Conflict
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Partner:
      type: object
      description: The account the calling API key belongs to. `GET /me` is the cheapest way to confirm a key works and to discover which partner it authenticates as.
      required:
        - id
        - display_name
        - kind
        - status
        - settings
      properties:
        id:
          type: string
          example: prt_01H8ABCDEFGHJKMNPQRSTVWXYZ
        display_name:
          type: string
          example: Acme Payments Sdn Bhd
        kind:
          type: string
          description: Account classification. Platform-owned and read-only to partners — there is no integration endpoint that writes it.
          enum:
            - partner
            - platform
          example: partner
        status:
          type: string
          description: Account status. Platform-owned and read-only over this API. Note that `id`, `kind`, `status` and `display_name` are all deliberately excluded from the settings write path.
          enum:
            - active
            - suspended
          example: active
        settings:
          type: object
          additionalProperties: true
          description: "Partner-configurable settings (e.g. tier, country, vertical). Free-form apart from the keys typed below: this is a partner-owned bag, not a fixed record."
          properties:
            primary_currency:
              type: string
              example: MYR
              description: |-
                The currency code the partner chose to denominate single-number headlines in, set from the Partner Dashboard. OPTIONAL, and absent for any partner who never set one. Consumers MUST NOT substitute a default when it is missing — Bayar never converts between currencies, so an assumed code puts a denomination nobody chose on a real number. Render the absent case as unknown instead.

                Deliberately unconstrained on READ, unlike CurrencyCode on the write path: the column predates the write path (BAY-260) and can still hold whatever was put there by hand, so a client that trusts a pattern here will be wrong about a real row. Handle an unrecognized value as unrecognized; do not repair it.
    CurrencyCode:
      type: string
      description: |-
        A currency the platform can denominate in — the exact set the ledger carries, which is NOT the ISO-4217 set. USDT, USDC, BTC and ETH are here because Bayar's ledger genuinely holds them; validating against a bare `^[A-Z]{3}$` would reject a real denomination while accepting "ZZZ", which the platform cannot price, format, or settle.

        Uppercase, exact. A lowercase code is rejected rather than coerced, so the column keeps one canonical spelling. Kept in sync with internal/money's scale table by a conformance test — adding a currency there without adding it here fails the build.
      enum:
        - AUD
        - BDT
        - BHD
        - BRL
        - BTC
        - CAD
        - CDF
        - CHF
        - CNY
        - EGP
        - ETH
        - EUR
        - GBP
        - GHS
        - GMD
        - GNF
        - HKD
        - IDR
        - INR
        - JPY
        - KES
        - KRW
        - KWD
        - MXN
        - MYR
        - MZN
        - NGN
        - NZD
        - OMR
        - PHP
        - PKR
        - RWF
        - SGD
        - SLE
        - THB
        - TZS
        - UGX
        - USD
        - USDC
        - USDT
        - VND
        - XAF
        - XOF
        - ZAR
        - ZMW
    Direction:
      type: string
      description: Which way the money moves. `collection` pulls funds FROM a payer INTO the partner's balance; `payout` pushes funds OUT to a beneficiary. It is fixed at creation and never changes.
      enum:
        - collection
        - payout
      example: collection
    Amount:
      type: object
      description: "A sum of money: a decimal STRING plus its currency. Never a float — binary floating point cannot represent 0.10 exactly, and a payments ledger that rounds is a ledger that loses money. Parse it with a decimal type."
      required:
        - amount
        - currency
      properties:
        amount:
          type: string
          description: Decimal as string, in major units ("150.00", not 15000).
          example: "150.00"
        currency:
          type: string
          description: Currency code, uppercase. See CurrencyCode for the exact set.
          example: MYR
      example:
        amount: "150.00"
        currency: MYR
    Payment:
      type: object
      description: One collection or payout. `presentment` is what the payer was asked for; `settlement` is what the partner is credited in. Bayar never applies an FX rate of its own, so for a same-currency payment the two agree.
      required:
        - id
        - state
        - direction
        - payment_method
        - presentment
        - settlement
        - settled_total
        - reversed_total
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: Prefixed ULID. Stable, sortable by creation time.
          example: pay_01H8ABCDEFGHJKMNPQRSTVWXYZ
        merchant_reference:
          type: string
          description: The partner's own reference, echoed back verbatim. Bayar neither parses nor enforces uniqueness on it.
          example: order-4821
        state:
          type: string
          description: "Position in the payment state machine. `succeeded`, `failed`, `cancelled`, `reversed` and `expired` are TERMINAL — a payment never leaves them, so a client may stop polling once it sees one. Treat an unrecognised value as non-terminal rather than as an error: states are added over time."
          enum:
            - created
            - routing
            - authorizing
            - authorized
            - sending
            - processing
            - partial
            - succeeded
            - failed
            - cancelled
            - reversed
            - expired
          example: succeeded
        direction:
          $ref: "#/components/schemas/Direction"
        payment_method:
          type: string
          description: The rail this payment runs on. Adapter-specific and open-ended — new methods appear as PSPs are onboarded, so do not switch exhaustively on it.
          example: online_banking
        presentment:
          $ref: "#/components/schemas/Amount"
        settlement:
          $ref: "#/components/schemas/Amount"
        settled_total:
          type: string
          description: How much has actually settled so far, in the SETTLEMENT currency. Equals `settlement.amount` on a clean success; less on a `partial`.
          example: "150.00"
        reversed_total:
          type: string
          description: How much has been reversed (refund or chargeback), in the settlement currency.
          example: "0.00"
        next_action:
          type: object
          additionalProperties: true
          nullable: true
          description: What the PAYER must do next, when the rail needs them to do something — follow a redirect, scan a QR. Null once no action is outstanding. The shape varies by `type`; read `type` first and treat unknown types as "cannot be handled here" rather than guessing.
          example:
            type: redirect
            url: https://psp.example/pay/6f2a1c
        created_at:
          type: string
          format: date-time
          example: 2026-07-14T09:31:22Z
        updated_at:
          type: string
          format: date-time
          example: 2026-07-14T09:33:05Z
      example:
        id: pay_01H8ABCDEFGHJKMNPQRSTVWXYZ
        merchant_reference: order-4821
        state: succeeded
        direction: collection
        payment_method: online_banking
        presentment:
          amount: "150.00"
          currency: MYR
        settlement:
          amount: "150.00"
          currency: MYR
        settled_total: "150.00"
        reversed_total: "0.00"
        created_at: 2026-07-14T09:31:22Z
        updated_at: 2026-07-14T09:33:05Z
    ListPaymentsResponse:
      type: object
      description: One page of payments, newest first. Pass `next_cursor` back as `?cursor=` to fetch the following page; stop when it comes back empty. Do not construct cursors — they are opaque and their encoding is not contractual.
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Payment"
        next_cursor:
          type: string
          description: Empty when there are no more pages
          example: eyJpZCI6InBheV8wMUg4QUJDIn0
      example:
        data:
          - id: pay_01H8ABCDEFGHJKMNPQRSTVWXYZ
            merchant_reference: order-4821
            state: succeeded
            direction: collection
            payment_method: online_banking
            presentment:
              amount: "150.00"
              currency: MYR
            settlement:
              amount: "150.00"
              currency: MYR
            settled_total: "150.00"
            reversed_total: "0.00"
            created_at: 2026-07-14T09:31:22Z
            updated_at: 2026-07-14T09:33:05Z
          - id: pay_01H8BCDEFGHJKMNPQRSTVWXYZA
            merchant_reference: payout-1190
            state: processing
            direction: payout
            payment_method: bank_transfer
            presentment:
              amount: "2400.00"
              currency: MYR
            settlement:
              amount: "2400.00"
              currency: MYR
            settled_total: "0.00"
            reversed_total: "0.00"
            created_at: 2026-07-14T08:02:10Z
            updated_at: 2026-07-14T08:02:44Z
        next_cursor: eyJpZCI6InBheV8wMUg4QUJDIn0
    PaymentTimelineEntry:
      type: object
      description: One recorded state transition. Append-only — entries are never edited or removed.
      required:
        - to_state
        - reason
        - actor_kind
        - at
      properties:
        from_state:
          type: string
          nullable: true
          description: Null for the genesis row
          example: authorizing
        to_state:
          type: string
          example: authorized
        reason:
          type: string
          description: Why the transition fired. A short machine-ish token, not prose.
          example: psp_callback
        actor_kind:
          type: string
          description: Who caused it — a dashboard `user`, an `api_key`, Bayar itself (`system`), or the `psp`.
          enum:
            - user
            - api_key
            - system
            - psp
          example: psp
        actor_id:
          type: string
          nullable: true
          example: psp_01H8MAXPAY0000000000000000
        at:
          type: string
          format: date-time
          example: 2026-07-14T09:32:41Z
    PaymentTimelineResponse:
      type: object
      description: The payment's full transition history, oldest first. This is the audit trail — use it to answer "how did it get here", not to poll for the current state (`GET /payments/{id}` is cheaper for that).
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/PaymentTimelineEntry"
      example:
        data:
          - from_state: null
            to_state: created
            reason: api_create
            actor_kind: api_key
            actor_id: apk_01H8ABCDEFGHJKMNPQRSTVWXYZ
            at: 2026-07-14T09:31:22Z
          - from_state: created
            to_state: authorizing
            reason: route_selected
            actor_kind: system
            actor_id: null
            at: 2026-07-14T09:31:23Z
          - from_state: authorizing
            to_state: succeeded
            reason: psp_callback
            actor_kind: psp
            actor_id: psp_01H8MAXPAY0000000000000000
            at: 2026-07-14T09:33:05Z
    CreatePaymentRequest:
      type: object
      description: Send `Idempotency-Key` with this. Without one, a network timeout leaves you unable to tell "never created" from "created, response lost" — and retrying blind charges the payer twice.
      required:
        - amount
        - currency
        - method
        - direction
      properties:
        merchant_reference:
          type: string
          description: Your own reference for this payment. Echoed back on every read.
          example: order-4821
        amount:
          type: string
          description: Decimal string in major units. Must have no more decimal places than the currency allows (2 for MYR, 0 for JPY) — an over-precise amount is a 400, never a silent round.
          example: "150.00"
        currency:
          allOf:
            - $ref: "#/components/schemas/CurrencyCode"
          example: MYR
        method:
          type: string
          description: The rail to use. Which values are valid depends on the routed PSP and the country.
          example: online_banking
        direction:
          $ref: "#/components/schemas/Direction"
        country:
          type: string
          description: ISO-3166-1 alpha-2 country of the payer or beneficiary. Feeds routing.
          example: MY
        notification_url:
          type: string
          description: Per-payment webhook override. Leave unset to use the account's configured webhook endpoints, which is the normal case.
          example: https://merchant.example/hooks/bayar
        return_url:
          type: string
          description: Where to send the payer's browser after a redirect-based rail completes.
          example: https://merchant.example/checkout/return
        instrument:
          type: object
          additionalProperties: true
          description: Per-attempt payer details, shaped by `method` — a bank code for online banking, a beneficiary id for a payout. Consult the method's own docs.
          example:
            bank_code: MBB0227
            payer_name: Nurul A.
        metadata:
          type: object
          additionalProperties: true
          description: Free-form key/value bag stored with the payment and returned on read. Never interpreted by Bayar. Do not put anything here that the payment engine needs to behave correctly.
          example:
            cart_id: "88213"
      example:
        merchant_reference: order-4821
        amount: "150.00"
        currency: MYR
        method: online_banking
        direction: collection
        country: MY
        return_url: https://merchant.example/checkout/return
    Balance:
      type: object
      description: |-
        One currency's position. `available` is what can be paid out right now; `held` is the gross settled position; `reserved` is the part of it fenced off against pending payouts and disputes. `available = held - reserved`.

        There is one row per currency the partner holds, and Bayar never converts between them — a MYR balance cannot fund a USD payout.
      required:
        - currency
        - held
        - reserved
        - available
      properties:
        currency:
          type: string
          example: MYR
        held:
          type: string
          description: Gross settled position, decimal string.
          example: "48250.00"
        reserved:
          type: string
          description: Fenced off against pending payouts and disputes.
          example: "2400.00"
        available:
          type: string
          description: Free to pay out. `held` minus `reserved`.
          example: "45850.00"
      example:
        currency: MYR
        held: "48250.00"
        reserved: "2400.00"
        available: "45850.00"
    Dispute:
      type: object
      description: |
        A chargeback, return, inquiry or retrieval against one payment. `amount` and `currency` are the DISPUTED PAYMENT's presentment figures — the `disputes` table carries no amount column of its own, and Bayar never applies an FX rate.
      required:
        - id
        - payment_id
        - psp_id
        - kind
        - state
        - amount
        - currency
        - created_at
        - updated_at
      properties:
        id:
          type: string
          example: dsp_01H8ABCDEFGHJKMNPQRSTVWXYZ
        payment_id:
          type: string
          description: The payment being disputed.
          example: pay_01H8ABCDEFGHJKMNPQRSTVWXYZ
        psp_id:
          type: string
          example: psp_01H8MAXPAY0000000000000000
        operation_id:
          type: string
          nullable: true
          example: null
        kind:
          type: string
          enum:
            - chargeback
            - return
            - inquiry
            - retrieval
          example: chargeback
        state:
          type: string
          description: "`won`, `lost`, `accepted` and `expired` are terminal. `evidence_required` is the one that needs you to act, by `due_at`."
          enum:
            - received
            - evidence_required
            - under_review
            - won
            - lost
            - accepted
            - expired
          example: evidence_required
        due_at:
          type: string
          format: date-time
          nullable: true
          description: When Bayar needs evidence by. Distinct from the PSP's own `evidence.deadline_at`.
          example: 2026-07-28T23:59:59Z
        psp_dispute_ref:
          type: string
          nullable: true
          example: MP-CB-99213
        resolved_at:
          type: string
          format: date-time
          nullable: true
          example: null
        amount:
          type: string
          description: Decimal string; the disputed payment's presentment amount
          example: "150.00"
        currency:
          type: string
          example: MYR
        evidence:
          allOf:
            - $ref: "#/components/schemas/DisputeEvidence"
          type: object
          nullable: true
          description: Returned only by GET /disputes/{id}; omitted from list responses.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DisputeEvidence:
      type: object
      description: |
        The v1 ALLOWLISTED projection of a dispute's evidence (BAY-233 §3, DR-1…DR-5). This is deliberately NOT the PSP's raw evidence payload.

        Chargeback evidence from a card PSP routinely carries cardholder PII — name, PAN fragments, billing address, AVS/CVV results. Passing that through verbatim would drag Postgres, its backups, the BFF and the dashboard into PCI-DSS cardholder-data scope. Bayar holds no PAN and intends to keep it that way, so the response carries only these fields and the server drops every other key.

        Adding a field here is a security decision, not a schema chore: it must go through Security. Evidence DOCUMENTS are never returned — only a count and an opaque PSP-side reference to fetch them out of band.
      additionalProperties: false
      properties:
        reason_code:
          type: string
          description: PSP/scheme reason code, e.g. Visa "4855"
        reason_description:
          type: string
          description: Human-readable gloss of reason_code
        psp_status:
          type: string
          description: The PSP's own dispute status string; NOT the Bayar `state`
        psp_evidence_ref:
          type: string
          description: Opaque PSP-side handle for the evidence package — a reference, never the package
        document_count:
          type: integer
          description: How many evidence documents the PSP holds. A count, not the content
        submitted_at:
          type: string
          format: date-time
          description: When evidence was submitted to the scheme
        deadline_at:
          type: string
          format: date-time
          description: The PSP's evidence deadline. Distinct from the dispute's own `due_at`
    ListDisputesResponse:
      type: object
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Dispute"
        next_cursor:
          type: string
    Beneficiary:
      type: object
      description: A stored payout destination. Reusable across payouts.
      required:
        - id
        - type
        - account_ref
        - details
        - status
      properties:
        id:
          type: string
          example: ben_01H8ABCDEFGHJKMNPQRSTVWXYZ
        type:
          type: string
          description: Which kind of destination this is. Determines what `details` must carry.
          enum:
            - bank_account
            - iban
            - upi_vpa
            - pix_key
            - wallet
            - card
          example: bank_account
        account_ref:
          type: string
          description: The destination identifier itself — account number, IBAN, VPA, PIX key.
          example: "157203004488"
        details:
          type: object
          additionalProperties: true
          description: Type-specific fields the rail needs — bank code and holder name for a `bank_account`, and so on. Required keys vary by `type`.
          example:
            bank_code: MBBEMYKL
            account_holder: Acme Payments Sdn Bhd
        currency:
          type: string
          description: Currency this destination can receive. Omitted when the rail is currency-agnostic.
          example: MYR
        status:
          type: string
          description: Closed set, enforced by a CHECK on the column. DELETE soft-deletes by setting `disabled` rather than removing the row.
          enum:
            - active
            - disabled
          example: active
      example:
        id: ben_01H8ABCDEFGHJKMNPQRSTVWXYZ
        type: bank_account
        account_ref: "157203004488"
        details:
          bank_code: MBBEMYKL
          account_holder: Acme Payments Sdn Bhd
        currency: MYR
        status: active
    CreateBeneficiaryRequest:
      type: object
      required:
        - type
        - account_ref
      properties:
        type:
          type: string
          description: The destination kind. Enforced by a CHECK on the column, not by the handler — so an unlisted value fails as a 500, not a 400. Send one of these.
          enum:
            - bank_account
            - iban
            - upi_vpa
            - pix_key
            - wallet
            - card
          example: bank_account
        account_ref:
          type: string
          example: "157203004488"
        details:
          type: object
          additionalProperties: true
          example:
            bank_code: MBBEMYKL
            account_holder: Acme Payments Sdn Bhd
        currency:
          type: string
          example: MYR
      example:
        type: bank_account
        account_ref: "157203004488"
        details:
          bank_code: MBBEMYKL
          account_holder: Acme Payments Sdn Bhd
        currency: MYR
    UpdateBeneficiaryRequest:
      type: object
      description: Partial update — omitted fields are left unchanged. `type` is immutable.
      properties:
        account_ref:
          type: string
          example: "157203004499"
        details:
          type: object
          additionalProperties: true
          example:
            bank_code: MBBEMYKL
            account_holder: Acme Payments Sdn Bhd
        currency:
          type: string
          example: MYR
        status:
          type: string
          description: Enforced by the column's CHECK, not the handler — an unlisted value fails as a 500, not a 400.
          enum:
            - active
            - disabled
          example: disabled
      example:
        status: disabled
    Error:
      type: object
      description: |-
        The error envelope every non-2xx response uses. Branch on `error.code`, not on `error.message` — the code is contractual, the message is human-facing and may be reworded at any time.

        Codes in use today: `bad_request`, `validation_error`, `invalid_request`, `invalid_currency`, `insufficient_scope`, `not_found`, `no_next_action`, `conflict`, `idempotency_conflict`, `concurrent_modification`, `endpoint_disabled`, `in_progress`, `last_minting_key`, `not_failed`, `unknown_scope`, `no_route`, `export_too_large`, `rate_limited`, `psp_error`, `internal`. Treat an unrecognised code as a generic failure of its HTTP status class rather than as a client bug.
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: Stable, machine-readable. Branch on this.
              example: validation_error
            message:
              type: string
              description: Human-readable. Not contractual — never parse it.
              example: amount has more decimal places than MYR allows
      example:
        error:
          code: validation_error
          message: amount has more decimal places than MYR allows
