openapi: 3.1.0
info:
  title: FireBuddy API
  version: "1.0.0"
  description: |
    Public REST API for FireBuddy — a software platform built on Fire.com's
    Open Banking infrastructure. Use this API to create funded-account-to-
    funded-account payment requests, retrieve payment status, list payees,
    and read live balances + transactions from a merchant's connected Fire
    account.

    All endpoints are authenticated with a merchant API key:
    `Authorization: Bearer fb_live_xxxxxxxxxxxxxxxx`

    FireBuddy is software only. All money movement is performed by
    Fire Financial Services Limited:
      • Fire-EU: regulated by the Central Bank of Ireland (Ref. C58301)
      • Fire-UK: authorised by the Financial Conduct Authority (Ref. 900983)
  contact:
    name: FireBuddy Support
    url: https://firebuddy.info
    email: support@firebuddy.info
  license:
    name: Proprietary
servers:
  - url: https://srjrxyobhfegimgqfpor.supabase.co/functions/v1/firebuddy-api
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: Payment Requests
    description: Create and inspect Fire Open Banking payment requests.
  - name: Payments
    description: Read recorded payment rows after the bank flow completes.
  - name: Invoices
    description: Create, read, update, send and reconcile invoices.
  - name: Payees
    description: List the merchant's saved payout destinations.
  - name: Payouts
    description: |
      Send money to one of the merchant's Fire-approved payees. Needs a key
      with `payouts:write` (off by default, switched on per key by FireBuddy)
      and the merchant's payout limits set. Every batch is approved by the
      merchant in the Fire app before money moves; results arrive as
      `payout.completed` / `payout.failed` webhooks.
  - name: Revolut payout runs
    description: |
      Pay many people at once through the merchant's Revolut Business
      account. FireBuddy creates ONE Revolut payment draft holding every
      payment; **money moves only when the merchant presses Send in the
      Revolut Business app** (FireBuddy never holds Revolut's PAY scope).
      Needs a key with `payouts:write`, the merchant's Revolut payouts switched
      on by FireBuddy, and Revolut Business connected with "prepare payouts".
      Results arrive per payment as `payout.completed` / `payout.failed`.
  - name: Accounts
    description: Read live balances from the merchant's connected Fire accounts.
  - name: Transactions
    description: Read recent transactions from a Fire account.
  - name: Exports
    description: CSV exports, VAT summaries and compliance bundles for reconciliation and audit.
  - name: Audit
    description: Merchant-side audit log of mutating actions taken via the API.
paths:
  /me:
    get:
      tags: [Payments]
      summary: Verify the key + check readiness
      description: |
        Call first. Read `ready` — the single source of truth. When false, show
        `message` and link `action_url` (do not re-derive readiness from the
        individual flags). `ready: true` means payments/invoices can be created.

        Also returns `connected_as` — the business name plus the label of the
        key that made the call. **Display it verbatim in your settings UI after
        a successful test.** A config field can never show a saved secret back,
        so this string is the only way a human can confirm the app is wired to
        the right business with the right key.
      responses:
        "200":
          description: Readiness
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:         { type: boolean }
                  ready:      { type: boolean, description: "Single source of truth — true = can take payments." }
                  status:     { type: string, enum: [ready, connect_fire] }
                  message:    { type: string }
                  action_url: { type: string }
                  connected_as:
                    type: string
                    description: 'Human-readable identity of this connection, e.g. `Core Kids · key "NEW PORTAL KIDS TEST"`. Show it after a successful test.'
                  api_key:
                    type: object
                    properties:
                      label:      { type: string, nullable: true, description: "The name given to this key in FireBuddy → API Keys." }
                      created_at: { type: string, format: date-time, nullable: true }
                      source:     { type: string, nullable: true, description: "merchant_api_keys (REST key) or api_keys (legacy widget key)." }
                  setup:
                    type: object
                    properties:
                      api_key:         { type: boolean }
                      fire_connected:  { type: boolean }
                      manual_transfer: { type: boolean }
                      webhook:         { type: boolean }
                  merchant:   { type: object }
                  fire_env:   { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /payment-methods:
    get:
      tags: [Payment Requests]
      summary: What to offer a known customer
      description: |
        Ask BEFORE creating a request whether this customer has a remembered
        bank (from a previous settled payment), so your checkout can render
        "⚡ Pay with Bank of Ireland" instead of a generic pay button.
        Presentation facts only — no bank uuid, no account data. Preferences
        are per-currency. When the merchant's `remembered_bank` feature is
        off, the answer is honestly `bank_remembered: false`, never an error.
      parameters:
        - name: email
          in: query
          required: true
          schema: { type: string, format: email }
          description: The customer's email, same lowercased address used on their payments.
        - name: currency
          in: query
          schema: { type: string, enum: [EUR, GBP], default: EUR }
      responses:
        "200":
          description: What to offer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  currency: { type: string, enum: [EUR, GBP] }
                  bank_remembered: { type: boolean }
                  bank_name:
                    type: string
                    nullable: true
                    example: "Bank of Ireland Personal"
        "400":
          description: Missing/invalid email parameter.

  /payment-requests:
    post:
      tags: [Payment Requests]
      summary: Create a payment request
      description: |
        Creates a Fire Open Banking payment request. Returns a hosted
        `paymentUrl` you can redirect or open the customer to. The customer
        completes the payment in their bank's app; FireBuddy records the
        result via the Fire webhook.

        **Revolut-only merchants** (`revolut_only` flag on + Revolut checkout
        connected — a merchant closing their Fire account): no Fire request is
        made. `code` is a FireBuddy request code (`FBR` + 9 uppercase
        alphanumerics), `provider` is `revolut`, and `paymentUrl` / `pay_url` /
        `picker_url` / `checkout_url` are all FireBuddy's checkout
        (`https://firebuddy.info/pay?pr=<paymentId>`). Store and match the code
        exactly as before — webhooks carry it as `fire_payment_code`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePaymentRequest"
            examples:
              minimal:
                summary: Minimal payload
                value:
                  amount: 49.99
                  description: "Order #1234"
              vat:
                summary: With VAT — send this if your checkout charges tax
                value:
                  amount: 1.23
                  description: "Order ORD-1785424883641"
                  tax_amount: 0.23
                  tax_rate: 0.23
              full:
                summary: With email + custom return URL
                value:
                  amount: 199.00
                  currency: GBP
                  description: "Annual subscription"
                  reference: "SUB-2026-001"
                  email: "customer@example.com"
                  returnUrl: "https://yoursite.com/thanks"
      responses:
        "200":
          description: Payment request created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentRequestCreated"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditExhausted" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "502": { $ref: "#/components/responses/UpstreamError" }
  /aspsps:
    get:
      tags: [Payment Requests]
      summary: List banks (ASPSPs) for deep-linking
      description: |
        The banks available for open banking payments, so you can send a customer
        straight to their own bank instead of through Fire's picker:
        `{pay_url_template}` with `{code}` from a payment request and the chosen
        `aspspUuid`.

        Requires `PERM_BUSINESS_GET_ASPSPS` on the merchant's Fire application —
        off by default. A 403 means that switch, not a bad key.

        The list changes rarely. Cache it; don't call it per checkout.
      parameters:
        - name: currency
          in: query
          required: false
          schema: { type: string, enum: [EUR, GBP] }
          description: Filter to banks payable in this currency.
      responses:
        "200":
          description: Bank list
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  pay_url_template:
                    type: string
                    description: Build the deep link from this — sandbox and live hosts differ.
                    example: "https://payments.fire.com/{code}/aspsps/{aspspUuid}"
                  aspsps:
                    type: array
                    items:
                      type: object
                      properties:
                        aspspUuid: { type: string, format: uuid }
                        alias: { type: string, example: "Bank of Ireland" }
                        logoUrl: { type: string, nullable: true }
                        country: { type: string, example: "IE" }
                        currency: { type: string, example: "EUR" }
        "403":
          description: PERM_BUSINESS_GET_ASPSPS not enabled on the merchant's Fire app
        "401": { $ref: "#/components/responses/Unauthorized" }
  /payment-requests/{code}:
    get:
      tags: [Payment Requests]
      summary: Get payment status by Fire payment code
      parameters:
        - in: path
          name: code
          required: true
          schema: { type: string }
          description: The Fire payment code returned from `POST /payment-requests`
      responses:
        "200":
          description: Payment record
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /payments/{id}:
    get:
      tags: [Payments]
      summary: Get a payment by FireBuddy payment ID
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Payment record (full)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentDetailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /webhooks:
    get:
      tags: [Payments]
      summary: List registered webhook endpoints
      description: One merchant account supports multiple endpoints (one per tool); events fan out to all active endpoints. Secrets are never returned here.
      responses:
        "200":
          description: Endpoint list
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:         { type: string, format: uuid }
                        url:        { type: string }
                        label:      { type: string, nullable: true }
                        is_active:  { type: boolean }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Payments]
      summary: Register a webhook endpoint for your tool
      description: |
        Registers a receiver for this merchant's payment events
        (payment.authorised, payment.completed, invoice.paid, payment.failed).
        Every event goes to every active endpoint — there is no per-endpoint
        event filter, so a receiver must ignore events it does not handle.
        `payment.authorised` means the payer's bank approved and the money
        follows (`settled: false` until `payment.completed`). Fulfilment rule:
        approve the order on `payment.authorised`, reconcile on
        `payment.completed`, and revoke on `payment.failed` (its
        `was_authorised: true` means the bank pulled an authorised payment).
        Every event carries `provider` (`fire` or `revolut`) — which rail the
        customer paid through; `reference` is the reference you created the
        payment with on both rails.
        Deliveries are
        HMAC-SHA256 signed with THIS endpoint's secret. The secret is returned
        once in the response — store it. Optionally supply your own `secret`
        (min 16 chars).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:    { type: string, description: "https:// receiver URL" }
                label:  { type: string }
                secret: { type: string, description: "Optional; generated when omitted." }
      responses:
        "201":
          description: Registered (includes the secret — shown only once)
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /webhooks/{id}:
    patch:
      tags: [Payments]
      summary: Enable/disable an endpoint
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [is_active]
              properties:
                is_active: { type: boolean }
      responses:
        "200": { description: Updated }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Payments]
      summary: Remove an endpoint
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /bank-details:
    get:
      tags: [Payments]
      summary: The merchant's manual bank-transfer account
      description: |
        Returns the merchant's own receiving account for a "pay by bank transfer
        instead" fallback. Only show the fallback when `manual_transfer_enabled`
        is true and the currency's fields are present (EUR → iban/bic, GBP →
        sort_code/account_number). `GET /accounts` returns Fire balances (ICAN),
        not the payable IBAN/BIC — use this endpoint for the transfer account.
      responses:
        "200":
          description: Manual bank-transfer details
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_transfer_enabled: { type: boolean }
                  account_holder:  { type: string, nullable: true }
                  iban:            { type: string, nullable: true }
                  bic:             { type: string, nullable: true }
                  sort_code:       { type: string, nullable: true }
                  account_number:  { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /offers:
    post:
      tags: [Offers]
      summary: Create an offer landing page
      description: |
        Creates a one-page public offer (title, headline, bullets, price, Pay
        button) plus its payment link, and returns the shareable `offer_url`
        (`https://firebuddy.info/offer?o={slug}`). The page renders the raw
        fields; the AI-designed layout is applied from Dashboard → Offers.

        **Feature-gated:** requires `offer_pages` on the account — a 403 means
        the flag, not a bad key. VAT maths matches the dashboard: the linked
        payment link stores the net; the offer stores the VAT-inclusive total.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, price]
              properties:
                title:              { type: string }
                price:              { type: number, minimum: 0.01, description: "Major units — what the customer pays." }
                currency:           { type: string, enum: [EUR, GBP], default: EUR }
                tax_rate:           { type: number, description: "Fraction, e.g. 0.23 (a value of 1 or more returns 400). Omit it to use the client's own VAT rate if one is set on their record, otherwise 0." }
                price_includes_vat: { type: boolean, default: true }
                headline:           { type: string }
                description:        { type: string }
                summary:            { type: string, description: "Working summary the AI designer uses later." }
                bullets:            { type: array, items: { type: string }, description: "What's-included checklist (max 10)." }
                image_url:          { type: string, format: uri, description: "https product photo shown on the page." }
                expires_in_days:    { type: integer }
                max_orders:         { type: integer, minimum: 1, description: "Order limit, counted from Fire-CONFIRMED sales; the page shows sold-out and the checkout refuses beyond it. 1 = one buyer." }
                countdown:          { type: boolean, default: false, description: "Stream mode: live countdown to the expiry on the page; enforced at pay time too." }
      responses:
        "201":
          description: Offer created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:              { type: string, format: uuid }
                  slug:            { type: string }
                  offer_url:       { type: string }
                  title:           { type: string }
                  price:           { type: number, description: "VAT-inclusive total shown on the page." }
                  net:             { type: number }
                  currency:        { type: string }
                  tax_rate:        { type: number }
                  payment_link_id: { type: string, format: uuid }
                  max_orders:      { type: integer, nullable: true }
                  countdown:       { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: offer_pages feature not enabled on this account }
  /quick/invoice:
    post:
      tags: [Invoices]
      summary: Quick invoice — one call, no questions
      description: |
        The fast path. Send only what a person would say — who, how much, what
        for — and FireBuddy resolves the rest from the merchant's account: the
        client (email exact / name fuzzy, most-recently-invoiced wins a tie),
        the next invoice number in the merchant's own sequence, the merchant's
        usual VAT rate (explicit `tax_rate` wins; `vat:false` forces none; otherwise the client's own rate if set on their record), the
        net backed out so the total lands on the number given, a pay link, and
        an email when the client has an address. Internally re-dispatches to
        `POST /invoices`, so idempotency, client creation and audit are the
        proven path. Returns the created invoice plus `resolved`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, for]
              properties:
                to:        { type: string, description: "Client name or email. Omit for an unnamed invoice." }
                amount:    { type: number, minimum: 0.01, description: "What the customer pays, major units (VAT-inclusive if VAT applies)." }
                for:       { type: string, description: "What it's for — becomes the single line item." }
                currency:  { type: string, enum: [EUR, GBP], default: EUR }
                due_date:  { type: string, format: date }
                tax_rate:  { type: number, description: "Fraction; only if stated. Otherwise the client's own rate if set, else the merchant's usual rate." }
                vat:       { type: boolean, description: "false = explicitly no VAT." }
                send:      { type: boolean, description: "false = don't email. Default: email when the client has an address." }
      responses:
        "201":
          description: Invoice created (same shape as POST /invoices) plus what was resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoice_url: { type: string }
                  resolved:
                    type: object
                    properties:
                      client:          { type: string, nullable: true }
                      client_matched:  { type: boolean }
                      client_email:    { type: string, nullable: true }
                      invoice_number:  { type: string }
                      tax_rate:        { type: number }
                      tax_rate_source: { type: string, enum: [given, "this client's rate", "your usual rate", none] }
                      emailed:         { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /invoices/chase:
    post:
      tags: [Invoices]
      summary: Chase everyone who owes you
      description: |
        Re-sends the reminder email (Pay Now + QR) for every sent-but-unpaid
        invoice that is overdue — or older than `older_than_days`. **Defaults
        to a preview** (`dry_run:true`): returns `would_chase` and sends
        nothing. Pass `dry_run:false` to send. Uses the same email path as
        `POST /invoices/{id}/send`. Clients without an email are listed under
        `skipped_no_email` (they need a phone call). Audit-logged.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                dry_run:         { type: boolean, default: true }
                older_than_days: { type: integer, minimum: 1 }
      responses:
        "200":
          description: Preview or result
          content:
            application/json:
              schema:
                type: object
                properties:
                  dry_run:          { type: boolean }
                  would_chase:      { type: array, items: { type: object } }
                  chased:           { type: array, items: { type: object } }
                  skipped_no_email: { type: array, items: { type: object } }
                  count:            { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /whatsapp/link:
    post:
      tags: [Invoices]
      summary: Link a WhatsApp number (returns a 6-digit code)
      description: |
        Starts binding a phone so the merchant can run FireBuddy by WhatsApp
        message. Returns a single-use 6-digit code valid 15 minutes; the
        merchant WhatsApps it to FireBuddy's number FROM the phone being
        linked. On success the number gets its own API key ("WhatsApp · +353…"
        on the API Keys page) and every message rides the audited API path.
        A number already linked to a different account is refused (409).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, description: "International format, +353…" }
                label: { type: string }
      responses:
        "200":
          description: Code issued (or already linked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone:        { type: string }
                  code:         { type: string }
                  expires_at:   { type: string, format: date-time }
                  send_to:      { type: string, nullable: true, description: "FireBuddy's WhatsApp number to message." }
                  instructions: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { description: number linked to a different account }
    delete:
      tags: [Invoices]
      summary: Unlink a WhatsApp number (revokes its key)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [phone], properties: { phone: { type: string } } }
      responses:
        "200": { description: Unlinked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /whatsapp/links:
    get:
      tags: [Invoices]
      summary: List linked WhatsApp numbers
      responses:
        "200":
          description: Linked and pending numbers (never the keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  links: { type: array, items: { type: object } }
                  display_number: { type: string, nullable: true, description: FireBuddy's WhatsApp number to message }
                  configured: { type: boolean, description: false until the platform number is set }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /feed:
    put:
      tags: [Offers]
      summary: Publish the merchant's public offer feed
      description: |
        Sets the merchant's public handle; the feed goes live at
        `https://firebuddy.info/feed/{slug}` — every active, PURCHASABLE
        offer as structured JSON (price, includes, remaining, ends_at, buy
        URLs) plus a schema.org OfferCatalog JSON-LD twin. Availability comes
        from the same function the checkout enforces, so nothing is listed
        that can't be bought. Opt-in: no slug, no feed (404). Slug is
        lowercase letters/digits/hyphens, 3–40 chars, globally unique.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug]
              properties:
                slug: { type: string, pattern: "^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$" }
      responses:
        "200":
          description: Published
          content:
            application/json:
              schema:
                type: object
                properties:
                  slug:     { type: string }
                  feed_url: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { description: slug already taken }
    delete:
      tags: [Offers]
      summary: Unpublish the offer feed
      responses:
        "200": { description: Unpublished — the feed URL now returns 404 }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /invoices:
    post:
      tags: [Invoices]
      summary: Create an invoice
      description: |
        Creates an invoice with line items. Optionally mints a Fire payment
        link (`create_payment_link`) and/or has FireBuddy email the invoice to
        the customer (`send`). If you send the invoice yourself, pass
        `mark_sent: true` to record it as sent (FireBuddy does not email in
        that case).

        **Idempotent by `invoice_number`:** if a non-cancelled invoice with
        the same `invoice_number` already exists on this account, that
        invoice is returned with `already: true` and NO duplicate is
        created — safe to retry and safe to re-run syncs. Use
        `PATCH /invoices/{id}` to change an existing invoice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_number, items]
              properties:
                invoice_number: { type: string }
                client_name:    { type: string }
                client_email:   { type: string, format: email }
                currency:       { type: string, default: EUR, enum: [EUR, GBP] }
                due_date:       { type: string, format: date }
                tax_rate:       { type: number, description: "Fraction, e.g. 0.23 for 23%", default: 0 }
                notes:          { type: string }
                items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [description, quantity, unit_price]
                    properties:
                      description: { type: string }
                      quantity:    { type: number }
                      unit_price:  { type: number }
                      product_id:  { type: string, format: uuid }
                create_payment_link: { type: boolean, default: false, description: "Mint a Fire pay link and attach it to the invoice." }
                return_url:          { type: string, description: "Return URL for the Fire payment page (with create_payment_link)." }
                mark_sent:           { type: boolean, default: false, description: "Record as 'sent' — you emailed it yourself. FireBuddy will not email." }
                send:                { type: boolean, default: false, description: "FireBuddy emails the invoice to the customer (Pay Now + QR). Pair with create_payment_link for a live pay button." }
                expires_after_days:
                  type: integer
                  minimum: 0
                  maximum: 365
                  description: |
                    Order-flow expiry: auto-cancel this invoice if still unpaid
                    after N days. DEFAULT when omitted: an invoice that mints a
                    pay link with no due_date and send:false is treated as an
                    order/checkout and auto-cancels after 14 days — keeps
                    abandoned baskets out of the merchant's Outstanding
                    receivables. Pass 0 to opt out, or set a due_date (a real
                    receivable is never auto-cancelled). If the customer pays
                    late anyway, settlement marks the invoice paid regardless.
            examples:
              createAndSend:
                summary: Create and have FireBuddy send it
                value:
                  invoice_number: "INV-1001"
                  client_name: "Jane Doe"
                  client_email: "jane@example.com"
                  currency: "EUR"
                  due_date: "2026-07-15"
                  items: [{ description: "Consultation", quantity: 1, unit_price: 100 }]
                  create_payment_link: true
                  send: true
      responses:
        "201":
          description: Invoice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:                { type: string, format: uuid }
                  invoice_number:    { type: string }
                  status:            { type: string, enum: [draft, sent, paid, cancelled] }
                  subtotal:          { type: number }
                  tax_amount:        { type: number }
                  total:             { type: number }
                  currency:          { type: string }
                  invoice_url:
                    type: string
                    description: |
                      The link to share with the customer: the branded hosted
                      invoice (line items, VAT, merchant branding) with a Pay
                      button. Prefer this over payment_url when handing a link
                      to a person — when the merchant has Revolut checkout on,
                      it also offers Revolut Pay, Apple Pay, Google Pay and card.
                  checkout_url:
                    type: string
                    description: |
                      Same page as invoice_url — the one to give customers.
                      Whichever way they pay, `invoice.paid` fires for this
                      invoice.
                  payment_url:       { type: string, nullable: true, description: "Direct Fire checkout URL (skips the branded invoice page)." }
                  payment_reference: { type: string, nullable: true }
                  client_id:         { type: string, format: uuid, nullable: true }
                  created_at:        { type: string, format: date-time }
                  sent:              { type: boolean, description: "Present when send:true was requested." }
                  send_error:        { type: string, description: "Present when send:true was requested but the email failed (invoice is still created)." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditExhausted" }
    get:
      tags: [Invoices]
      summary: List invoices
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [draft, sent, paid, cancelled] } }
        - { in: query, name: from,   schema: { type: string, format: date } }
        - { in: query, name: to,     schema: { type: string, format: date } }
        - { in: query, name: limit,  schema: { type: integer, default: 50, maximum: 200 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: Invoice list
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:    { type: array, items: { type: object } }
                  total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /invoices/{id}:
    get:
      tags: [Invoices]
      summary: Get an invoice with line items
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Invoice with items }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Invoices]
      summary: Update a draft/sent invoice
      description: "Allowed transitions: draft → sent|cancelled, sent → cancelled. Also updates due_date/notes."
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                status:   { type: string, enum: [sent, cancelled] }
                due_date: { type: string, format: date }
                notes:    { type: string }
      responses:
        "200": { description: Updated invoice }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Invoice is paid or cancelled and cannot be updated }
  /invoices/{id}/send:
    post:
      tags: [Invoices]
      summary: Email the invoice to the customer
      description: |
        FireBuddy emails the invoice (Pay Now button + QR linking to the hosted
        invoice page) and marks it `sent`. Defaults the recipient to the linked
        client's email. Pair invoice creation with `create_payment_link` so the
        hosted page has a live Fire pay button (bank-transfer details show
        regardless).
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                to:      { type: string, format: email, description: "Recipient override; defaults to the linked client's email." }
                subject: { type: string, description: "Subject override." }
                message: { type: string, description: "Intro line shown above the line items." }
      responses:
        "200":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:          { type: string, format: uuid }
                  status:      { type: string, example: sent }
                  sent:        { type: boolean }
                  to:          { type: string }
                  invoice_url: { type: string }
        "400": { description: No recipient email (pass `to` or attach a client with an email) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Invoice is cancelled }
        "502": { description: Email could not be sent (MailCub unavailable) }
  /invoices/{id}/mark-paid:
    post:
      tags: [Invoices]
      summary: Record an out-of-band payment
      description: |
        For integrators that collect payment on their own side. With
        `confirmed: false` (default) FireBuddy logs the claim but does not flip
        the invoice — it marks an invoice paid only when a real Fire lodgement
        confirms funds. With `confirmed: true` FireBuddy records a completed
        payment, flips the invoice to paid, and fires the `payment.completed` +
        `invoice.paid` webhooks. Idempotent for already-paid invoices.
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:             { type: number, description: "Defaults to the invoice total." }
                currency:           { type: string }
                fee_amount:         { type: number }
                paid_at:            { type: string, format: date-time }
                external_reference: { type: string, description: "Your own payment id (stored as the reference)." }
                payment_method:     { type: string, description: "Free-text label, e.g. 'card', 'bank_transfer'." }
                confirmed:          { type: boolean, default: false }
      responses:
        "200": { description: "Recorded (paid when confirmed:true, otherwise logged)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Invoice is cancelled }
  /invoices/{id}/reply:
    post:
      tags: [Invoices]
      summary: Send the merchant's answer to a queried invoice
      description: |
        The other half of a drafted reply. `GET /receivables` returns
        `drafted_reply` — FireBuddy's suggested wording, written from the
        invoice's own line items for a query, or asking for the payment date,
        amount and reference when a customer says they already paid — plus
        `draft_proposal` if a credit is suggested.

        Show that to the merchant, let them edit it, then send it here. There
        is deliberately **no "send the draft as-is" shortcut**: the caller has
        to pass the message text, which means somebody saw it.

        A suggested credit is advice. Nothing in FireBuddy applies it to the
        invoice — issue a corrected invoice yourself if you agree with it.

        Answering resets the query, so normal chasing resumes on that invoice.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string, maxLength: 4000, description: The exact wording to send. }
      responses:
        "200":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  invoice_number: { type: string }
                  to: { type: string }
                  sent: { type: boolean }
        "404": { description: Invoice not found }
        "422": { description: That client has no email address on file }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /receivables:
    get:
      tags: [Invoices]
      summary: Risk-ranked list of everything you are owed
      description: |
        Every open (sent/overdue) invoice, scored 0-100 and ordered worst
        first, with the reasons attached so the ranking can be argued with.

        Score components: lateness (max 40), that client's own payment record
        (25), size relative to your median invoice (20), and reminders already
        sent without reply (15).

        **Read `suggested_action`, not just the score.** It overrides the
        ranking wherever a reminder would be the wrong instruction:

        | action | meaning |
        |---|---|
        | `verify_payment` | **customer says they already paid** — chasing paused, check Fire. Never auto-settled |
        | `stop_chasing` | customer asked not to be contacted, or is not the right person |
        | `on_hold` | customer promised a date; chasing resumes by itself after `hold_until` |
        | `at_risk` | **not late yet**, but this client's own record says it will be — nudge before the date |
        | `chase` | safe to send the standard reminder |
        | `needs_reply` | client raised a query — answer it, never chase |
        | `handle_personally` | reliable client unusually late, or a large sum — call, don't email |
        | `human_review` | reminder budget spent, no email on file, or client cancelled |
        | `paused` | reminders silenced on this invoice |
        | `wait` | not due yet |
      responses:
        "200":
          description: Portfolio summary plus the ranked invoices
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary:
                    type: object
                    properties:
                      open_invoices: { type: integer }
                      open_value: { type: number }
                      overdue_invoices: { type: integer }
                      overdue_value: { type: number }
                      needs_your_reply: { type: integer }
                      handle_personally: { type: integer }
                      ready_to_chase: { type: integer }
                      by_band: { type: object }
                  invoices:
                    type: array
                    items:
                      type: object
                      properties:
                        invoice_number: { type: string }
                        client: { type: string, nullable: true }
                        amount: { type: number }
                        currency: { type: string }
                        due_date: { type: string, format: date, nullable: true }
                        days_overdue: { type: integer }
                        risk_score: { type: integer }
                        risk_band: { type: string, enum: [critical, high, medium, low, watch] }
                        reasons: { type: array, items: { type: string } }
                        suggested_action: { type: string, enum: [verify_payment, stop_chasing, needs_reply, on_hold, handle_personally, human_review, at_risk, paused, wait, chase] }
                        action_reason: { type: string }
                        reminders_sent: { type: integer }
                        client_history: { type: object }
                        client_query: { type: string, nullable: true }
                        reply_intent:
                          type: string
                          nullable: true
                          enum: [claims_paid, dispute, promise_to_pay, hardship, wrong_recipient, stop_contact, acknowledgement, unclear]
                          description: What the customer's most recent reply was understood to mean.
                        hold_until: { type: string, format: date, nullable: true, description: Chasing is paused until this date (customer promised payment). }
                        drafted_reply: { type: string, nullable: true, description: A reply FireBuddy has written and is waiting for the merchant to approve. Never sent automatically. }
                        draft_proposal:
                          type: object
                          nullable: true
                          description: Suggested resolution — advisory only, never applied to the invoice.
                          properties:
                            kind: { type: string, enum: [credit, resend_invoice, no_change, needs_detail] }
                            amount: { type: number, nullable: true }
                            note: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /payees:
    get:
      tags: [Payees]
      summary: List saved payout destinations for the merchant
      responses:
        "200":
          description: Array of payees
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Payee" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /payments/{id}/refund:
    post:
      summary: Refund a Revolut payment back to the payer
      description: |
        Refunds a payment taken through Revolut checkout (Revolut Pay or card)
        back to the payer's original method, from the merchant's own Revolut
        Merchant balance. Needs the account's `api_refunds` switch (FireBuddy
        turns it on). `{id}` is the payment id, or the payment-request `code`
        the payment was made from. Omit `amount` to refund everything left.
        One refund per `idempotency_key` per payment: a repeat returns the
        current state with `duplicate: true` and never refunds twice.
        A payment made by bank transfer can't be pulled back — it returns
        `409 not_revolut`; refund it from the bank. Errors: `over_refundable`
        (422, with `refundable`), `not_refundable`, `refund_in_progress`,
        `revolut_not_connected` (409), `revolut_refused` (502),
        `payment_not_found` (404), `refunds_not_enabled` (403).
        `recorded: false` means Revolut refunded it but FireBuddy's record
        didn't update — check the payment before trying again.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idempotency_key]
              properties:
                amount: { type: number, description: "Major units, e.g. 25.00. Omit for a full refund of what's left." }
                reason: { type: string, maxLength: 140 }
                idempotency_key: { type: string, minLength: 8, maxLength: 120 }
            example: { amount: 25.00, reason: "Week cancelled", idempotency_key: "hq-refund-booking-8812-wk3" }
      responses:
        "201":
          description: Refunded at Revolut
          content:
            application/json:
              example: { payment_id: "…", ok: true, refund_id: "…", refund_state: "completed", status: "completed", refunded_amount: 25.00, currency: "GBP", recorded: true }
        "200": { description: "Repeat of the same idempotency_key — current state, duplicate: true" }
  /refunds:
    post:
      summary: Refund many Revolut payments at once (e.g. a cancelled club)
      description: |
        Same rules as `POST /payments/{id}/refund`, for up to 200 payments.
        Each item succeeds or fails on its own; bank-transfer payments come
        back `not_revolut` so you can list them to refund by hand.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idempotency_key, payments]
              properties:
                idempotency_key: { type: string, minLength: 8, maxLength: 120 }
                reason: { type: string, maxLength: 140 }
                payments:
                  type: array
                  maxItems: 200
                  items:
                    type: object
                    required: [payment_id]
                    properties:
                      payment_id: { type: string, description: "Payment id or payment-request code" }
                      amount: { type: number, description: "Omit for a full refund" }
            example: { idempotency_key: "hq-club-1204-cancelled", reason: "Club cancelled", payments: [ { payment_id: "FBR-ABC123" }, { payment_id: "FBR-DEF456", amount: 20 } ] }
      responses:
        "200":
          description: Per-payment results
          content:
            application/json:
              example: { refunded: 1, failed: 1, results: [ { payment_id: "…", ok: true, refunded_amount: 60, status: "refunded" }, { payment_id: "…", ok: false, code: "not_revolut", error: "This payment wasn't made through Revolut checkout…" } ] }
  /payouts:
    post:
      tags: [Payouts]
      summary: Pay an approved payee
      description: |
        Creates a Fire bank-transfer batch to one payee and submits it. Fire
        then applies the merchant's own approval (the Fire app); the response
        is `202` with `status: awaiting_bank_approval`. When Fire completes
        or rejects the batch, FireBuddy sends `payout.completed` or
        `payout.failed` to every registered webhook endpoint; you can also poll
        `GET /payouts/{id}`.

        Checked in this order, nothing sent unless all pass:
        1. the key has `payouts:write` (`403 payouts_not_enabled`);
        2. `idempotency_key` (required) — a repeat returns the original payout
           with `200` and `duplicate: true`, never a second payout; the same key
           for a different payee/amount is `409 idempotency_conflict`;
        3. the payee belongs to the merchant (`404 payee_not_found`), is
           approved in the Fire app (`409 payee_not_approved`) and is linked to
           that Fire payee (`409 payee_not_linked` — Sync / Link with Fire in
           FireBuddy). Editing a payee's bank details unlinks it until it is
           approved and linked again;
        4. both limits are set (`403 caps_not_set`), the amount is within the
           per-payout limit (`422 over_payout_cap`) and today's API payouts on
           the whole ACCOUNT — every key, Fire and Revolut together, UTC day,
           per currency, failed excluded — stay within the daily limit
           (`422 over_daily_cap`);
        5. the Fire account has the funds (`422 insufficient_funds`);
        6. Fire accepts the batch (`502 fire_refused`, with `stage`).
        If Fire refuses, nothing is kept and the same `idempotency_key` can be
        retried. `502 fire_uncertain` means Fire may hold the batch and it
        could not be cancelled: the payout stays open and is settled from
        Fire — check the Fire app and never retry with a new
        `idempotency_key`. Retry other `5xx` once with the same key.

        About 20 seconds after submitting, FireBuddy reads the batch again
        (expect the call to take ~20s). If Fire has already completed it, the
        merchant's Fire rules approved it without a person: the response has
        `auto_approved: true` and a `warning`, and payouts:write is switched
        off on the key until FireBuddy switches it back on.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePayout" }
            example:
              payee_id: "9b2f5d0e-3c1a-4e7b-9a52-6d1f0c2b8e41"
              amount: 182.50
              currency: GBP
              reference: "Commission Oct"
              idempotency_key: "offload-ref-emma-3f9a1c2b"
              metadata: { partner: "emma" }
      responses:
        "202":
          description: Submitted to Fire; waiting for the merchant's approval in the Fire app
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "200":
          description: Repeat of an earlier request with the same idempotency_key — the original payout
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Payout" }
                  - type: object
                    properties: { duplicate: { type: boolean, enum: [true] } }
        "400": { $ref: "#/components/responses/PayoutRefused" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PayoutRefused" }
        "404": { $ref: "#/components/responses/PayoutRefused" }
        "409": { $ref: "#/components/responses/PayoutRefused" }
        "422": { $ref: "#/components/responses/PayoutRefused" }
        "502": { $ref: "#/components/responses/PayoutRefused" }
  /payouts/{id}:
    get:
      tags: [Payouts]
      summary: Status of a payout created through the API
      description: |
        `awaiting_bank_approval` → `sent` | `failed`. If the payout is still
        open, FireBuddy asks Fire first (at most every 30 seconds).
        `sent` means Fire reports the batch COMPLETE — nothing else.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The payout
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /revolut/payees:
    get:
      tags: [Revolut payout runs]
      summary: The merchant's Revolut Business payees (live)
      description: |
        Read live from Revolut (counterparties), one entry per payee account.
        Revolut-user payees (added by Revtag) have no bank account: they come back
        with `revolut_user: true` and `currency`/`account_id` null, and are paid
        Revolut-to-Revolut in the run's currency.
        Use `id` as `counterparty_id` in `POST /payout-runs`. Account numbers
        are never returned — `masked_account` is the last four digits only.
        Payees are added in the Revolut Business app.
      responses:
        "200":
          description: Payees
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RevolutPayee" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PayoutRunRefused" }
        "409": { $ref: "#/components/responses/PayoutRunRefused" }
        "502": { $ref: "#/components/responses/PayoutRunRefused" }
  /payout-runs:
    post:
      tags: [Revolut payout runs]
      summary: Prepare one Revolut payment draft for many payments
      description: |
        Creates ONE payment draft in the merchant's Revolut Business account
        containing every payment, and returns `202` with
        `status: awaiting_bank_approval`. Nothing is paid until the merchant
        opens Transfers → Drafts in the Revolut Business app and presses Send.

        Checked in this order; nothing is created unless all pass:
        1. the key has `payouts:write` (`403 payouts_not_enabled`), Revolut
           payouts are on (`403 revolut_payouts_not_enabled`), Revolut is
           connected with WRITE (`409 revolut_not_connected` /
           `revolut_read_only`);
        2. `idempotency_key` (required) — a repeat returns the original run with
           `200` and `duplicate: true`, never a second draft; the same key with
           a different currency / total / number of payments is
           `409 idempotency_conflict`;
        3. every `counterparty_id` is a live Revolut payee
           (`404 counterparty_not_found`) with an account in `currency`
           (`409 counterparty_currency_mismatch`). If the payee has more than
           one account in `currency`, send that payment's `account_id` (from
           `GET /revolut/payees`) — otherwise `409 account_ambiguous`; an
           unknown `account_id` is `404 account_not_found`;
        4. amounts are positive with at most 2 decimals (`400 bad_request`),
           each within the per-payout limit (`422 over_payout_cap`), and the
           run plus today's other API payouts on this ACCOUNT — every key,
           Fire payouts and Revolut runs together, UTC day, this currency,
           failed excluded — within the daily limit (`422 over_daily_cap`).
           Both limits must be set (`403 caps_not_set`);
        5. the merchant's active Revolut account in `currency` (the fullest, if
           several) holds the total (`422 insufficient_funds`,
           `409 no_source_account`);
        6. Revolut accepts the draft (`502 revolut_refused`). The run is then
           kept as `failed` with `error` — retry with a NEW idempotency_key.
           `502 revolut_uncertain` means Revolut did not answer clearly (or
           accepted without an id) and a draft may exist: the run stays open,
           still counts towards the daily limit, and is settled by reference.
           Check Revolut before creating another run — never retry it with a
           new idempotency_key.

        Each payment gets a unique short `reference`: your text (trimmed to 12
        characters, unusual characters replaced) + a space + a 5-character
        token, at most 18 characters (what UK Faster Payments shows). That
        reference is how FireBuddy recognises the payment once it is sent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePayoutRun" }
            example:
              title: "CK wages Oct 2026"
              currency: GBP
              idempotency_key: "hq-wages-2026-10"
              payments:
                - { counterparty_id: "c8d1e5a0-1f2b-4c3d-9e8f-0a1b2c3d4e5f", amount: 412.50, reference: "CK wages Oct", metadata: { hq_ref: "timesheet:123" } }
                - { counterparty_id: "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", amount: 288.00, reference: "CK wages Oct" }
      responses:
        "202":
          description: Draft created in Revolut; waiting for the merchant to press Send
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutRun" }
        "200":
          description: Repeat of an earlier request with the same idempotency_key — the original run
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/PayoutRun" }
                  - type: object
                    properties: { duplicate: { type: boolean, enum: [true] } }
        "400": { $ref: "#/components/responses/PayoutRunRefused" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PayoutRunRefused" }
        "404": { $ref: "#/components/responses/PayoutRunRefused" }
        "409": { $ref: "#/components/responses/PayoutRunRefused" }
        "422": { $ref: "#/components/responses/PayoutRunRefused" }
        "502": { $ref: "#/components/responses/PayoutRunRefused" }
  /payout-runs/{id}:
    get:
      tags: [Revolut payout runs]
      summary: Status of a Revolut payout run
      description: |
        If the run is still open (and not checked in the last 30 seconds),
        FireBuddy first reads the merchant's Revolut transfers since the run
        was created and matches each open payment by its unique reference and
        amount: Revolut `completed` → `sent`; `declined` / `failed` /
        `reverted` → `failed`. Run status: all sent → `sent`; some sent and the
        rest failed → `part_sent`; all failed → `failed`. A background sweep
        does the same every 10 minutes for runs up to 14 days old.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The run
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutRun" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PayoutRunRefused" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/PayoutRunRefused" }
  /accounts:
    get:
      tags: [Accounts]
      summary: Live Fire account balances for the merchant
      description: |
        Calls Fire's `/business/v1/accounts` endpoint server-side using the
        merchant's stored credentials and returns the simplified account list.
      responses:
        "200":
          description: Account list
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: "#/components/schemas/Account" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "502": { $ref: "#/components/responses/UpstreamError" }
  /transactions:
    get:
      tags: [Transactions]
      summary: Recent transactions for a Fire account
      parameters:
        - in: query
          name: ican
          schema: { type: integer }
          description: Fire account ID. Defaults to the merchant's primary EUR/GBP account.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
      responses:
        "200":
          description: Transaction list
          content:
            application/json:
              schema:
                type: object
                properties:
                  ican: { type: integer }
                  transactions:
                    type: array
                    items: { $ref: "#/components/schemas/Transaction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "502": { $ref: "#/components/responses/UpstreamError" }
  /exports/payments.csv:
    get:
      tags: [Exports]
      summary: Download payments as CSV
      description: |
        Streams a CSV file of all payments for the authenticated merchant.
        Includes a UTF-8 BOM so Excel opens it correctly. Optional `from` and
        `to` query parameters filter by `created_at` (ISO-8601, inclusive).
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
          description: Lower bound on `created_at`. Inclusive.
        - in: query
          name: to
          schema: { type: string, format: date-time }
          description: Upper bound on `created_at`. Inclusive.
      responses:
        "200":
          description: CSV file
          content:
            text/csv:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /exports/payouts.csv:
    get:
      tags: [Exports]
      summary: Download payouts as CSV
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: CSV file
          content:
            text/csv:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /exports/invoices.csv:
    get:
      tags: [Exports]
      summary: Download invoices as CSV
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: CSV file
          content:
            text/csv:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /exports/vat-summary:
    get:
      tags: [Exports]
      summary: VAT summary — both sides of the return, by currency × rate
      description: |
        Both sides of the VAT return on the invoice basis: sales (output VAT,
        T1) from ISSUED invoices (status sent/paid, by issue date) and
        purchases (input VAT, T2) from APPROVED supplier bills (status
        open/paid, by bill date), broken down by currency and rate, plus the
        net position per currency. Draft and cancelled/void documents are
        excluded on both sides. All figures are estimates from FireBuddy
        records — confirm with an accountant before filing.
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: VAT summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VatSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /audit-log:
    get:
      tags: [Audit]
      summary: Read recent audit log entries
      description: |
        Returns the most recent audit log entries for the authenticated
        merchant. Mutating API calls (creating a payment request, generating
        a GDPR export, etc.) write here automatically.
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
      responses:
        "200":
          description: Audit log entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items: { $ref: "#/components/schemas/AuditLogEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /exports/audit-log.csv:
    get:
      tags: [Exports]
      summary: Download audit log as CSV
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: CSV file
          content:
            text/csv:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /exports/me.json:
    get:
      tags: [Exports]
      summary: GDPR data portability export
      description: |
        Returns a JSON document containing every record FireBuddy holds for
        the authenticated merchant — settings, API key metadata (no secrets),
        payments, payouts, invoices, payees and the audit log. Generating an
        export writes a `gdpr_export.generated` entry to the audit log.
      responses:
        "200":
          description: GDPR export bundle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GdprExport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /exports/compliance-bundle:
    get:
      tags: [Exports]
      summary: Combined audit bundle (payments + payouts + invoices)
      description: |
        Returns a JSON bundle of every payment, payout and invoice in the
        requested range. Intended for handing to an external auditor or
        archiving for compliance purposes.

        White-label tenants can disable this endpoint by setting
        `feature_flags.allow_compliance_export = false` on their
        `platform_tenants` row. Merchants on the root platform are allowed
        by default.
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Compliance bundle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ComplianceBundle" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Compliance export disabled for this tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "fb_live_*"
      description: |
        Generate an API key from the FireBuddy dashboard → Developer view.
        Send it as `Authorization: Bearer fb_live_xxx` on every request.
  schemas:
    CreatePaymentRequest:
      type: object
      required: [amount, description]
      properties:
        amount:
          type: number
          format: float
          minimum: 0.01
          description: Amount in major units (e.g. `49.99` for €49.99). Cents are rounded server-side.
        currency:
          type: string
          enum: [EUR, GBP]
          default: EUR
        description:
          type: string
          maxLength: 100
          description: Shown to the customer in their bank app.
        reference:
          type: string
          description: |
            Your own reference. Truncated to 35 chars for EUR and 18 chars
            for GBP to satisfy Fire's `myRef` limits. Falls back to `description`.
        email:
          type: string
          format: email
          description: |
            Optional customer email — stored on the payment record for
            receipts, AND the key for the remembered-bank fast path: when the
            merchant has the `remembered_bank` feature enabled and this
            customer has previously SETTLED a payment, the response's
            `pay_url` deep-links straight into their own bank (no picker) and
            `bank_remembered` is `true`. Send the same lowercased address on
            every request for the same customer. Check ahead with
            `GET /payment-methods`.
        aspsp_uuid:
          type: string
          format: uuid
          description: |
            Optional bank pre-selection: deep-link the customer straight into
            this bank (uuid from `GET /aspsps`), skipping Fire's picker.
            Takes precedence over the remembered bank. Always show a
            "Not your bank?" escape using the response's `picker_url`.
        returnUrl:
          type: string
          format: uri
          description: |
            Override the merchant's default `fire_redirect_url`. The customer is
            sent here after the bank flow finishes. Defaults to
            `https://firebuddy.info/thank-you.html`.
        tax_amount:
          type: number
          format: float
          minimum: 0
          description: |
            **Send this if your checkout charges VAT.** How much of `amount` is
            VAT, in the same currency. `amount` stays the total the customer
            pays — this only says how it splits. A €1.23 charge made of €1.00
            net + 23% VAT is `amount: 1.23, tax_amount: 0.23`.

            Without it FireBuddy has no way to know the total contains VAT, so
            the merchant's Profit & Loss books the whole amount as net income
            and reports zero output VAT — which understates the VAT they owe.

            Takes precedence over `tax_rate` when both are sent, since this is
            the exact figure your checkout showed the customer.
        tax_rate:
          type: number
          format: float
          minimum: 0
          exclusiveMaximum: 1
          description: |
            The VAT rate `amount` was charged at, as a **decimal fraction** —
            `0.23` for 23%, `0.20` for 20%, `0.135` for 13.5%. Sending `23`
            is rejected with a 400.

            Use this instead of `tax_amount` if you'd rather FireBuddy do the
            arithmetic: it derives the VAT as
            `amount - amount / (1 + tax_rate)`.
    PaymentRequestCreated:
      type: object
      properties:
        paymentUrl:
          type: string
          format: uri
          example: "https://payments.fire.com/AbCdEf12"
        code:
          type: string
          description: Fire payment code — or, for a revolut_only merchant, a FireBuddy request code (`FBR` + 9 uppercase alphanumerics, never a Fire code). Use this to look the payment up via `GET /payment-requests/{code}`; webhooks carry it as `fire_payment_code`.
        provider:
          type: string
          enum: [fire, revolut]
          description: "`revolut` when the request was minted without Fire (revolut_only merchant): no Fire page exists, every URL is FireBuddy's checkout. `fire` otherwise (Revolut may still be offered on `checkout_url`)."
        paymentId:
          type: string
          format: uuid
          description: FireBuddy payment row ID. Use with `GET /payments/{id}` for the full record.
        paymentLinkId:
          type: string
          format: uuid
        checkout_url:
          type: string
          format: uri
          nullable: true
          description: |
            FireBuddy's checkout for THIS payment — give customers this. It
            offers every way the merchant accepts (Revolut Pay, Apple Pay,
            Google Pay, card and pay by bank when they have Revolut checkout
            on; Fire bank payment otherwise). Whichever the customer picks,
            this same `paymentId` / `code` completes and your webhooks carry
            your `reference`. `pay_url` is Fire's raw page (bank payment only).
        pay_url:
          type: string
          format: uri
          description: Alias of `paymentUrl`. Deep-linked into a specific bank when one was chosen (explicit `aspsp_uuid`, or the customer's remembered bank); otherwise Fire's bank picker.
        picker_url:
          type: string
          format: uri
          description: ALWAYS Fire's full bank picker for this request. Render it as the "Not your bank?" escape whenever `pay_url` is deep-linked — people change banks and pay business bills from personal accounts.
        bank_remembered:
          type: boolean
          description: True when `pay_url` was deep-linked from the customer's remembered bank (requires the merchant's `remembered_bank` feature and a previously settled payment under the same email).
        aspsp_uuid:
          type: string
          nullable: true
          description: The bank `pay_url` deep-links into, when one was chosen.
        aspsp_name:
          type: string
          nullable: true
          description: Display name of that bank — e.g. show "Continue with Bank of Ireland".
    Payment:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending, completed, failed, refunded]
        amount: { type: number, format: float }
        currency: { type: string, enum: [EUR, GBP] }
        customer_email: { type: string, format: email, nullable: true }
        fire_payment_code: { type: string }
        fire_ref: { type: string }
        authorised_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            When the payer authorised at their bank. Set while `status` is
            still `pending` — authorisation is not money, so the payment does
            not complete until Fire confirms settlement.
        funds_confirmed_at:
          type: string
          format: date-time
          nullable: true
          description: When the payer's bank confirmed the funds. Still not settlement.
        in_flight:
          type: boolean
          description: >-
            The payer authorised at their bank and the payment has not
            completed. Show "payment on its way" and stop offering to pay
            again — this is what stops customers paying twice. NEVER fulfil on
            this: an authorised payment can still fail. Fulfil on
            `settled` / `status: completed` only.
        settled:
          type: boolean
          description: >-
            The payment provider confirmed the money for this payment
            (`status: completed`) — Fire, or Revolut for its own orders.
            The only field that means payment.
        provider:
          type: string
          enum: [fire, revolut]
          description: Which rail this payment went through.
        invoice_paid:
          type: boolean
          description: >-
            Present (true) when this payment row is still open but its invoice
            was paid by a DIFFERENT payment — e.g. the customer opened the
            invoice page and paid there. Treat the order as paid; see
            `paid_by_payment_id`.
        paid_by_payment_id:
          type: string
          format: uuid
          description: The payment that actually paid the invoice (with `invoice_paid`).
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PaymentDetailed:
      allOf:
        - $ref: "#/components/schemas/Payment"
        - type: object
          properties:
            fee_amount: { type: number, format: float, nullable: true }
            amount_before_charges: { type: number, format: float, nullable: true }
            amount_after_charges: { type: number, format: float, nullable: true }
    Payee:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        iban: { type: string, nullable: true }
        bic: { type: string, nullable: true }
        currency: { type: string, enum: [EUR, GBP] }
        is_approved:
          type: boolean
          description: Approved in the merchant's Fire app. `POST /payouts` only pays approved payees.
        masked_account:
          type: string
          nullable: true
          description: Last four digits only, e.g. `•••• 4821` or `20-••-•• •••• 4821`. Show this instead of holding account numbers.
        created_at: { type: string, format: date-time }
    CreatePayout:
      type: object
      required: [payee_id, amount, idempotency_key]
      properties:
        payee_id: { type: string, format: uuid, description: "A payee id from `GET /payees` (must be `is_approved`)." }
        amount: { type: number, minimum: 0.01, description: "Major units, at most 2 decimals (182.50)." }
        currency: { type: string, enum: [EUR, GBP], description: "Optional; must match the payee's currency." }
        reference:
          type: string
          maxLength: 60
          description: Shown on the bank transfer. Trimmed to Fire's limits (18 characters for GBP, 35 for EUR).
        idempotency_key:
          type: string
          minLength: 8
          maxLength: 120
          description: Unique per payout you intend. Reuse it on every retry of the same payout.
        metadata:
          type: object
          description: Your own data (under 2,000 characters of JSON), echoed on the payout and its webhooks.
    Payout:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [processing, awaiting_bank_approval, sent, failed] }
        amount: { type: number }
        currency: { type: string, enum: [EUR, GBP] }
        payee_id: { type: string, format: uuid }
        reference: { type: string, nullable: true }
        idempotency_key: { type: string }
        metadata: { type: object, nullable: true }
        created_at: { type: string, format: date-time }
        sent_at: { type: string, format: date-time, nullable: true, description: "When FireBuddy saw Fire report the batch COMPLETE." }
        failure_reason: { type: string, nullable: true }
    PayoutWebhook:
      type: object
      description: |
        Body of `payout.completed` / `payout.failed` (flat JSON, same signing
        as every FireBuddy webhook: `x-firebuddy-signature` = hex HMAC-SHA256
        of the raw body with your endpoint's secret; `x-firebuddy-event` = the
        event). Sent once per payout created through the API. Delivery is
        best-effort without retries — poll `GET /payouts/{id}` as a backstop.
      properties:
        event: { type: string, enum: [payout.completed, payout.failed] }
        payout_id: { type: string, format: uuid }
        status: { type: string, enum: [sent, failed] }
        amount: { type: number }
        currency: { type: string, enum: [EUR, GBP] }
        payee_id: { type: string, format: uuid }
        reference: { type: string, nullable: true }
        idempotency_key: { type: string }
        metadata: { type: object, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        failure_reason: { type: string, nullable: true }
        fire_batch_status: { type: string, nullable: true, description: "Fire's raw batch status, e.g. COMPLETE, REJECTED." }
        timestamp: { type: string, format: date-time }
    RevolutPayee:
      type: object
      properties:
        id: { type: string, description: "Revolut counterparty id — send as `counterparty_id`." }
        name: { type: string }
        masked_account: { type: string, nullable: true, description: "Last four digits only, e.g. `•••• 4821`." }
        currency: { type: string, nullable: true }
        account_id: { type: string, nullable: true, description: "Revolut's id for this payee account." }
    CreatePayoutRun:
      type: object
      required: [currency, idempotency_key, payments]
      properties:
        title: { type: string, maxLength: 80, description: "Shown on the draft in Revolut Business." }
        currency: { type: string, enum: [GBP, EUR] }
        idempotency_key:
          type: string
          minLength: 8
          maxLength: 120
          description: Unique per run you intend. Reuse it on every retry of the same run.
        metadata: { type: object, description: "Your own data for the run (under 2,000 characters of JSON)." }
        payments:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: object
            required: [counterparty_id, amount, reference]
            properties:
              counterparty_id: { type: string, description: "An `id` from `GET /revolut/payees`." }
              account_id: { type: string, description: "Optional: the payee's receiving account (`account_id` from `GET /revolut/payees`). Required when the payee has more than one account in this currency." }
              amount: { type: number, minimum: 0.01, description: "Major units, at most 2 decimals." }
              reference: { type: string, description: "Your text; FireBuddy shortens it and adds a unique token (see above)." }
              metadata: { type: object, description: "Echoed on the item and its webhooks (under 2,000 characters of JSON)." }
    PayoutRun:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [awaiting_bank_approval, sent, part_sent, failed] }
        title: { type: string, nullable: true }
        currency: { type: string, enum: [GBP, EUR] }
        total: { type: number }
        draft_id: { type: string, nullable: true, description: "The Revolut payment draft id." }
        idempotency_key: { type: string }
        metadata: { type: object, nullable: true }
        error: { type: string, nullable: true, description: "Why the run failed, or why the draft is uncertain." }
        created_at: { type: string, format: date-time }
        items:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid, description: "The `payout_id` on this payment's webhooks." }
              counterparty_id: { type: string }
              counterparty_name: { type: string, nullable: true }
              amount: { type: number }
              currency: { type: string }
              reference: { type: string, description: "The unique reference on the bank transfer (≤ 18 characters)." }
              status: { type: string, enum: [awaiting_bank_approval, sent, failed] }
              metadata: { type: object, nullable: true }
              sent_at: { type: string, format: date-time, nullable: true }
              failure_reason: { type: string, nullable: true }
    PayoutRunItemWebhook:
      type: object
      description: |
        Body of `payout.completed` / `payout.failed` for one payment in a
        Revolut payout run (flat JSON, signed like every FireBuddy webhook).
        Sent once per payment, when FireBuddy sees Revolut complete or reject
        the transfer. Not sent for runs Revolut refused at creation (the POST
        already answered). Best-effort without retries — poll
        `GET /payout-runs/{id}` as a backstop.
      properties:
        event: { type: string, enum: [payout.completed, payout.failed] }
        payout_id: { type: string, format: uuid, description: "The item id." }
        run_id: { type: string, format: uuid }
        rail: { type: string, enum: [revolut] }
        status: { type: string, enum: [sent, failed] }
        amount: { type: number }
        currency: { type: string }
        counterparty_id: { type: string }
        reference: { type: string }
        metadata: { type: object, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        failure_reason: { type: string, nullable: true }
        revolut_transaction_id: { type: string, nullable: true }
    Account:
      type: object
      properties:
        ican: { type: integer, description: Fire account ID }
        name: { type: string }
        currency: { type: string, enum: [EUR, GBP] }
        balance: { type: number, format: float, description: Total balance in major units }
        availableBalance: { type: number, format: float }
        status: { type: string }
    Transaction:
      type: object
      properties:
        txnId: { type: string }
        date: { type: string, format: date-time }
        amount: { type: number, format: float }
        currency: { type: string }
        direction: { type: string, enum: [IN, OUT] }
        reference: { type: string }
        description: { type: string }
        balance: { type: number, format: float }
    VatSummary:
      type: object
      properties:
        from: { type: string, format: date-time, nullable: true }
        to: { type: string, format: date-time, nullable: true }
        breakdown:
          type: array
          items:
            type: object
            properties:
              currency: { type: string, enum: [EUR, GBP] }
              tax_rate: { type: number, format: float, description: "e.g. 0.23 for 23%" }
              invoice_count: { type: integer }
              net: { type: number, format: float }
              vat: { type: number, format: float }
              gross: { type: number, format: float }
        totals_by_currency:
          type: array
          items:
            type: object
            properties:
              currency: { type: string, enum: [EUR, GBP] }
              net: { type: number, format: float }
              vat: { type: number, format: float }
              gross: { type: number, format: float }
        basis:
          type: string
          enum: [invoice]
          description: Recognition basis for both sides of the summary.
        purchases_breakdown:
          type: array
          items:
            type: object
            properties:
              currency: { type: string, enum: [EUR, GBP] }
              vat_rate_percent: { type: number, format: float, description: "e.g. 23.0 for 23% (note: percent, unlike sales tax_rate which is a fraction)" }
              bill_count: { type: integer }
              net: { type: number, format: float }
              vat: { type: number, format: float }
              gross: { type: number, format: float }
        purchases_totals_by_currency:
          type: array
          items:
            type: object
            properties:
              currency: { type: string, enum: [EUR, GBP] }
              net: { type: number, format: float }
              vat: { type: number, format: float }
              gross: { type: number, format: float }
        vat_position_by_currency:
          type: array
          items:
            type: object
            properties:
              currency: { type: string, enum: [EUR, GBP] }
              output_vat: { type: number, format: float, description: "T1 — VAT on issued sales invoices" }
              input_vat: { type: number, format: float, description: "T2 — VAT on approved supplier bills" }
              net_position: { type: number, format: float, description: "T1 − T2; positive = payable, negative = reclaim" }
        note:
          type: string
          description: Basis and estimate disclaimer.
    ComplianceBundle:
      type: object
      properties:
        generated_at: { type: string, format: date-time }
        range:
          type: object
          properties:
            from: { type: string, format: date-time, nullable: true }
            to: { type: string, format: date-time, nullable: true }
        counts:
          type: object
          properties:
            payments: { type: integer }
            payouts: { type: integer }
            invoices: { type: integer }
        payments:
          type: array
          items: { type: object }
        payouts:
          type: array
          items: { type: object }
        invoices:
          type: array
          items: { type: object }
    AuditLogEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        action: { type: string, example: "payment_request.created" }
        resource_type: { type: string, nullable: true }
        resource_id: { type: string, nullable: true }
        actor: { type: string, nullable: true, example: "api" }
        actor_ip: { type: string, nullable: true }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
    GdprExport:
      type: object
      properties:
        generated_at: { type: string, format: date-time }
        format: { type: string, example: "firebuddy-gdpr-export-v1" }
        merchant_settings: { type: object, nullable: true }
        api_keys: { type: array, items: { type: object } }
        payments: { type: array, items: { type: object } }
        payouts: { type: array, items: { type: object } }
        invoices: { type: array, items: { type: object } }
        payees: { type: array, items: { type: object } }
        audit_log: { type: array, items: { $ref: "#/components/schemas/AuditLogEntry" } }
    Error:
      type: object
      properties:
        error: { type: string }
  responses:
    BadRequest:
      description: Missing or invalid request body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "amount is required" }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Invalid or missing API key" }
    NotFound:
      description: Resource does not exist for this merchant
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Payment not found" }
    Unprocessable:
      description: Merchant has not finished configuring Fire credentials
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Merchant has not configured Fire API credentials" }
    CreditExhausted:
      description: |
        The merchant's FireBuddy credit balance has no positive balance in
        either currency, so creating NEW payment requests / pay links is
        paused until they top up. Existing links and requests keep working
        and settling — only creation is gated. There is no deadline: a
        top-up lifts the pause instantly. `code` is stable; match on it.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:      { type: string }
              code:       { type: string, enum: [credit_exhausted] }
              top_up_url: { type: string }
          example:
            error: "FireBuddy credit exhausted — top up to create new payment requests. Existing payment links and requests continue to work and settle normally."
            code: credit_exhausted
            top_up_url: "https://firebuddy.info/dashboard#credit-vault"
    PayoutRefused:
      description: |
        The payout was not sent. `code` is stable — match on it:
        `bad_request`, `idempotency_key_required`, `payouts_not_enabled`,
        `caps_not_set`, `payee_not_found`, `payee_not_approved`,
        `currency_mismatch`, `idempotency_conflict`, `in_progress`,
        `over_payout_cap`, `over_daily_cap`, `insufficient_funds`,
        `no_source_account`, `fire_not_connected`, `fire_refused`.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              code:  { type: string }
          example: { error: "This payee is not approved in the Fire app yet. Approve it there, then Sync payees in FireBuddy.", code: payee_not_approved }
    PayoutRunRefused:
      description: |
        Nothing was sent. `code` is stable — match on it:
        `bad_request`, `idempotency_key_required`, `payouts_not_enabled`,
        `revolut_payouts_not_enabled`, `revolut_not_connected`,
        `revolut_read_only`, `caps_not_set`, `counterparty_not_found`,
        `counterparty_currency_mismatch`, `idempotency_conflict`,
        `in_progress`, `over_payout_cap`, `over_daily_cap`,
        `insufficient_funds`, `no_source_account`, `revolut_unavailable`,
        `revolut_refused`, `revolut_uncertain`.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              code:  { type: string }
              index: { type: integer, description: "Which payment, for per-payment errors." }
          example: { error: "payments[3]: no Revolut payee with that counterparty_id on this account.", code: counterparty_not_found, index: 3 }
    UpstreamError:
      description: Fire API rejected the upstream call
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Fire API rejected the payment request" }
