{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Invoice API",
    "version": "2026-09-19",
    "description": "Create, deliver, and reconcile invoices. Money is integer cents. Authorization: Bearer pst_... (create.invoice capability). Remediation contract: refunds now require a stable operation_key; legacy create intent or capture context may require explicit reconciliation. These guards intentionally reject previously unsafe operations. Local implementation; deployment/provider acceptance is tracked in the Business Workflows remediation plan.",
    "contact": {
      "email": "hello@pitchstation.ai"
    }
  },
  "servers": [
    {
      "url": "https://www.pitchstation.ai"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/invoices": {
      "post": {
        "summary": "Create an invoice (optionally send it in the same call)",
        "description": "external_ref binds a versioned immutable original intent: recipient, currency, issuer/default, TEST flag, items, tax/discount, document fields, metadata and initial paid/send intent. Identical replay returns the existing invoice without sending or paying again, even after edits or settlement. Changed intent: 409 REF_PAYLOAD_MISMATCH. Legacy rows lacking provable intent: 409 LEGACY_INTENT_UNVERIFIABLE with invoice_id/read URL; inspect, never recreate blindly. send:true prepares an issued representation before consuming the invoice number. Failed preparation leaves no consumed number; an issued invoice is never deleted just because a later notification fails. test:true suppresses invoice email and push and prohibits gateway collection; configured outbound business webhooks still carry is_test:true.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InvoiceCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Idempotent replay — the existing invoice for this external_ref"
          },
          "201": {
            "description": "Created (and sent, when send:true)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendResult"
                }
              }
            }
          },
          "409": {
            "description": "REF_PAYLOAD_MISMATCH | LEGACY_INTENT_UNVERIFIABLE | PUBLICATION_BUSY | NO_ITEMS | ZERO_TOTAL"
          },
          "429": {
            "description": "SEND_CAP — daily send cap for the plan tier"
          }
        }
      },
      "get": {
        "summary": "List invoices (+aging) or look up by external_ref",
        "parameters": [
          {
            "name": "external_ref",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "paid",
                "void",
                "overdue",
                "partially_paid"
              ]
            }
          },
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "client_email",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "issuer_id",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "invoices[] (+aging when unfiltered; test invoices excluded from aging)"
          }
        }
      }
    },
    "/api/invoices/{id}": {
      "get": {
        "summary": "Invoice detail + event timeline + live URL",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "description": "Owner-only current invoice, claims and events. invoice.refunds lists accepted/completed/status/operation_id; invoice.reconciliation lists associated pending/held provider events and reason codes. Unknown provider sessions are visible in the read-only operational audit, not assigned to an owner by untrusted metadata."
      },
      "patch": {
        "summary": "Edit (drafts freely; sent = revision on the same link; paid/void locked)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "TEST_IMMUTABLE | REF_IMMUTABLE"
          },
          "409": {
            "description": "LOCKED_PAID | LOCKED_PARTIAL"
          }
        },
        "description": "Drafts and sent unpaid invoices may be revised. Recipient changes normalize email and reconcile client/actual-owner ACL, access mode, team grants and credential generation before making new bytes visible. The slug stays stable; obsolete bearer tokens/passwords/view grants/forwards do not. The response returns a newly generated password when applicable; retain and deliver it. A failed disk swap keeps the share held: retry the owner send endpoint on the same invoice. PUBLICATION_BUSY means retry after the in-progress operation. Paid/void invoices remain locked."
      },
      "delete": {
        "summary": "Delete a draft (sent invoices are voided, never deleted)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "409": {
            "description": "Not a draft"
          }
        }
      }
    },
    "/api/invoices/{id}/send": {
      "post": {
        "summary": "Number, mint the tracked link, email the client",
        "description": "Client email matching a PitchStation account → account-locked link (no password). Otherwise link + password in the same email, rotated on resend. `{\"attach\": true}` attaches the archival PDF. Resend reuses the same URL.",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "Resent (same link)"
          },
          "201": {
            "description": "Sent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendResult"
                }
              }
            }
          },
          "429": {
            "description": "SEND_CAP"
          }
        }
      }
    },
    "/api/invoices/{id}/payments": {
      "post": {
        "summary": "Record a payment (blank amount = full balance)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount_cents": {
                    "type": "integer"
                  },
                  "method": {
                    "type": "string"
                  },
                  "reference": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recorded; full payment → status paid + receipt email"
          }
        },
        "description": "Accepts multipart/form-data with up to 3 evidence[] files (images or PDF, ≤15 MB each) to attach the bank confirmation to an owner-attested payment. Client-side claims arrive on the share page: POST /s/{slug}/invoice/paid-claim (JSON or multipart with method, amount_cents, paid_on, payer_name, reference, evidence[]), POST …/paid-claim/{pid}/evidence, DELETE …/paid-claim/{pid} (withdraw), GET …/invoice/claim-state — all gated exactly like the document (link token / account / password / view grant). Browser ownership uses a separate signed, host-only HttpOnly cookie, scoped to share/invoice and valid for a renewable 30 days by default; it survives view-grant rotation. Evidence/withdraw require this proof, not a caller-provided email or claim ID. Missing/expired proof does not automatically recover legacy claims; contact the sender. Raw proof and stored ownership hashes are never returned in JSON. Read open claims on GET /api/invoices/{id}: payments[] carries method, paid_on, payer_name, acknowledged_at and evidence[] metadata (never bytes); dismissed_claims[] carries reasons."
      }
    },
    "/api/invoices/{id}/pdf": {
      "get": {
        "summary": "Archival application/pdf (DRAFT stamp on unsent)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "PDF bytes"
          },
          "503": {
            "description": "PDF_RENDER_OFF | PDF_BUSY"
          }
        }
      }
    },
    "/api/invoices/{id}/snapshots": {
      "get": {
        "summary": "Compliance snapshots frozen at each send (sha256-indexed)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "snapshots[]"
          }
        }
      }
    },
    "/api/invoices/{id}/void": {
      "post": {
        "summary": "Void (number retained)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/invoices/{id}/credit-note": {
      "post": {
        "summary": "Credit note against a paid invoice",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "201": {
            "description": "Credit draft created (or same refund operation replayed). refund is null unless requested; accepted does not mean completed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "invoice": {
                      "type": "object"
                    },
                    "refund": {
                      "$ref": "#/components/schemas/RefundStatus"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "REFUND_KEY_REQUIRED | BAD_AMOUNT; no operation created"
          },
          "409": {
            "description": "NOT_PAID | SOURCE_ISSUER_UNRESOLVED | CAPTURE_CONTEXT_UNRESOLVED | REFUND_KEY_CONFLICT | OVER_REFUND | FULLY_REFUNDED | NO_GATEWAY_PAYMENT"
          },
          "503": {
            "description": "REFUNDS_PAUSED; no new reservation or note"
          }
        },
        "description": "Creates an owner-scoped correction draft inheriting source TEST mode and frozen issuer identity. Credit notes cannot accept positive payments, client claims, Checkout, payment reminders or recurrence. refund:true additionally reserves refundable balance and requests a Stripe refund, requiring operation_key before any note or money operation. Reuse the SAME key and identical intent after timeout; never generate another key to resolve uncertainty. Pending/requires_action/unknown are not completion and post no negative ledger row. Only succeeded sets refund.ok/completed true and emits invoice.refunded. Later failure appends one compensating entry and emits invoice.refund_failed. The accepted credit note remains if the provider leg fails. Legacy unbound captures require explicit reconciliation.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "refund": {
                    "type": "boolean",
                    "default": false
                  },
                  "operation_key": {
                    "type": "string",
                    "minLength": 8,
                    "maxLength": 128,
                    "pattern": "^[A-Za-z0-9_.:-]+$",
                    "description": "Required with refund:true. Generate once per action, persist before calling, retain on retries."
                  },
                  "items": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/InvoiceItem"
                    }
                  },
                  "notes": {
                    "type": "string"
                  },
                  "tax_cents": {
                    "type": "integer"
                  },
                  "discount_cents": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/invoices/{id}/remind": {
      "post": {
        "summary": "Manual reminder (scheduler covers due-3/due/+7/+14)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "description": "Sent non-credit invoices only. TEST returns suppressed:true and would_email without mail/push."
      }
    },
    "/api/invoices/{id}/recurrence": {
      "post": {
        "summary": "Monthly recurrence from this invoice",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "day_of_month": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 28
                  },
                  "auto_send": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          }
        }
      }
    },
    "/api/invoices/settings/profile": {
      "patch": {
        "summary": "Issuer identity on every artifact (frozen per-invoice at send)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "legal_name": {
                    "type": "string"
                  },
                  "tax_id": {
                    "type": "string"
                  },
                  "address": {
                    "type": "string"
                  },
                  "billing_email": {
                    "type": "string"
                  },
                  "footer_text": {
                    "type": "string"
                  },
                  "logo_base64": {
                    "type": "string",
                    "description": "PNG/JPEG/WebP only — transcoded server-side; SVG rejected. null clears."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "LOGO_FORMAT | LOGO_DECODE"
          },
          "413": {
            "description": "LOGO_TOO_LARGE"
          }
        }
      }
    },
    "/api/webhooks": {
      "get": {
        "summary": "List webhook endpoints (secrets never repeated)",
        "responses": {
          "200": {
            "description": "endpoints[], events[], max"
          }
        }
      },
      "post": {
        "summary": "Register a webhook endpoint (max 3; https only)",
        "description": "The signing secret is returned ONCE. Verify deliveries: X-PitchStation-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + '.' + rawBody); reject when |now - t| > 300s. Retries 1m/10m/1h/6h/24h then dead-letter. Endpoints resolving to private/link-local/metadata addresses are refused.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "invoice.sent",
                        "invoice.viewed",
                        "invoice.payment_claimed",
                        "invoice.paid",
                        "invoice.overdue",
                        "invoice.voided",
                        "invoice.credit_noted",
                        "invoice.refunded",
                        "invoice.refund_failed",
                        "invoice.payment_confirmed",
                        "invoice.claim_acknowledged",
                        "invoice.claim_dismissed"
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "endpoint + secret (once)"
          },
          "400": {
            "description": "BAD_URL | BAD_EVENTS"
          },
          "409": {
            "description": "WEBHOOK_LIMIT"
          }
        }
      }
    },
    "/api/webhooks/{id}": {
      "delete": {
        "summary": "Remove an endpoint",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/webhooks/{id}/deliveries": {
      "get": {
        "summary": "Delivery log (status, attempts, resolved IP)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "deliveries[]"
          }
        }
      }
    },
    "/api/webhooks/{id}/test": {
      "post": {
        "summary": "Send a signed ping to verify the receiver",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "delivery result"
          }
        }
      }
    },
    "/api/invoices/settings/rails": {
      "post": {
        "summary": "Opt this account into online collection (Pay now)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "rail": {
                    "type": "string",
                    "enum": [
                      "stripe"
                    ]
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "503": {
            "description": "COLLECT_OFF — collection disabled server-side"
          }
        }
      }
    },
    "/api/invoices/issuers": {
      "get": {
        "summary": "List billing identities (default issuer always present)",
        "responses": {
          "200": {
            "description": "issuers[]"
          }
        }
      },
      "post": {
        "summary": "Create an issuer (own legal block, logo, gap-free register)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "label": {
                    "type": "string"
                  },
                  "legal_name": {
                    "type": "string"
                  },
                  "tax_id": {
                    "type": "string"
                  },
                  "address": {
                    "type": "string"
                  },
                  "billing_email": {
                    "type": "string"
                  },
                  "footer_text": {
                    "type": "string"
                  },
                  "number_prefix": {
                    "type": "string"
                  },
                  "logo_base64": {
                    "type": "string",
                    "description": "PNG/JPEG/WebP only; transcoded server-side"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "issuer"
          }
        }
      }
    },
    "/api/invoices/issuers/{id}": {
      "patch": {
        "summary": "Update an issuer (audit-logged; issued documents never change)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "issuer"
          }
        }
      },
      "delete": {
        "summary": "Delete an unused, non-default issuer",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "409": {
            "description": "IN_USE | IS_DEFAULT"
          }
        }
      }
    },
    "/api/invoices/reconciliation": {
      "get": {
        "summary": "Month-end finance rollup",
        "parameters": [
          {
            "name": "month",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "issued / paid_gateway / paid_manual / refunded by currency+issuer, plus current outstanding/overdue"
          }
        }
      }
    },
    "/api/invoices/{id}/payments/{pid}/confirm": {
      "post": {
        "operationId": "confirmInvoiceClaim",
        "summary": "Confirm a payment claim — money actually arrived",
        "description": "Atomic current-state/balance validation and confirmation. Omitted amount uses the stored claim amount with identical validation. Only an open client claim on a sent non-credit receivable may transition; positive safe-integer amounts only. Identical already-confirmed replay returns idempotent_replay:true with no new receipt/event enqueue. Conflicting amount/note: CONFIRMATION_CONFLICT. One durable receipt intent and invoice.payment_confirmed event per committed confirmation; TEST mail/push suppressed. Transport retries after ambiguous SMTP acknowledgements can duplicate delivery.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "pid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The claim (payment row) id."
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount_cents": {
                    "type": "integer",
                    "description": "What actually arrived, if different from the claim."
                  },
                  "note": {
                    "type": "string",
                    "maxLength": 300
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmed or identical replay; updated invoice and idempotent_replay"
          },
          "400": {
            "description": "BAD_AMOUNT"
          },
          "409": {
            "description": "OVER_BALANCE | NOT_PAYABLE | CREDIT_NOT_PAYABLE | CLAIM_CLOSED | CONFIRMATION_CONFLICT | PUBLICATION_BUSY"
          }
        }
      }
    },
    "/api/invoices/{id}/payments/{pid}/acknowledge": {
      "post": {
        "operationId": "acknowledgeInvoiceClaim",
        "summary": "Acknowledge a claim — received, still clearing",
        "description": "A state between claimed and confirmed for cheques and wires in transit. The payer is emailed; the balance does not move; payment reminders stay paused. Emits invoice.claim_acknowledged.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "pid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The claim (payment row) id."
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "note": {
                    "type": "string",
                    "maxLength": 300,
                    "description": "Sent to the payer."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged"
          }
        }
      }
    },
    "/api/invoices/{id}/payments/{pid}/message": {
      "post": {
        "operationId": "messageInvoiceClaimPayer",
        "summary": "Ask the payer about a claim",
        "description": "Emails the payer (e.g. \"the screenshot cuts off the date\"). The claim stays open; an audit event records the message.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "pid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The claim (payment row) id."
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "message": {
                    "type": "string",
                    "maxLength": 1500
                  }
                },
                "required": [
                  "message"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sent"
          }
        }
      }
    },
    "/api/invoices/{id}/payments/{pid}": {
      "delete": {
        "operationId": "dismissInvoiceClaim",
        "summary": "Dismiss a claim — a reason is REQUIRED and is sent to the payer",
        "description": "Soft: the claim is kept with its reason (dismissed_claims[] on GET) and its evidence bytes are purged. Pass the reason as ?reason= (preferred for DELETE) or in a JSON body. 400 REASON_REQUIRED without one. Emits invoice.claim_dismissed. Reminders resume.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "pid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The claim (payment row) id."
          },
          {
            "name": "reason",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 300
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Dismissed"
          }
        }
      }
    },
    "/api/invoices/{id}/payments/{pid}/evidence/{eid}": {
      "get": {
        "operationId": "getInvoiceClaimEvidence",
        "summary": "Evidence bytes (owner only) — a transfer screenshot or cheque photo",
        "description": "Images are re-encoded JPEG served inline; PDFs are served as attachments. private, no-store. There is no public route for evidence. Append /thumb for the 320px thumbnail.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "pid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The claim (payment row) id."
          },
          {
            "name": "eid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "image/jpeg or application/pdf"
          },
          "410": {
            "description": "Redacted"
          }
        }
      },
      "delete": {
        "operationId": "redactInvoiceClaimEvidence",
        "summary": "Redact the file; keep the record",
        "description": "Removes the bytes and thumbnail; the evidence row stays as a tombstone (redacted_at) so the audit trail still shows a file was attached.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "pid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The claim (payment row) id."
          },
          {
            "name": "eid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redacted"
          }
        }
      }
    },
    "/api/invoices/{id}/refunds": {
      "get": {
        "summary": "Owner-only refund operation status",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "Current persisted status; do not refund again manually while pending/unknown",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "refunds": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RefundStatus"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Invoice not found or not owned"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (pst_...)"
      }
    },
    "parameters": {
      "id": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "integer"
        }
      }
    },
    "schemas": {
      "InvoiceItem": {
        "type": "object",
        "required": [
          "description"
        ],
        "properties": {
          "description": {
            "type": "string"
          },
          "qty": {
            "type": "number",
            "default": 1
          },
          "unit": {
            "type": "string"
          },
          "unit_cents": {
            "type": "integer",
            "description": "INTEGER CENTS — 250000 = $2,500.00"
          }
        }
      },
      "InvoiceCreate": {
        "type": "object",
        "properties": {
          "external_ref": {
            "type": "string",
            "maxLength": 128,
            "pattern": "^[A-Za-z0-9._:/-]+$",
            "description": "Your order id — idempotency key, unique per account, immutable"
          },
          "send": {
            "type": "boolean",
            "description": "Create + number + email atomically; failure creates nothing"
          },
          "test": {
            "type": "boolean",
            "description": "TEST- series, no emails, auto-purged after 30 days, immutable"
          },
          "attach": {
            "type": "boolean",
            "description": "With send:true — attach the archival PDF to the client email"
          },
          "client_name": {
            "type": "string"
          },
          "client_email": {
            "type": "string"
          },
          "client_company": {
            "type": "string"
          },
          "client_address": {
            "type": "string"
          },
          "currency": {
            "type": "string",
            "pattern": "^[A-Z]{3}$",
            "default": "HKD"
          },
          "issue_date": {
            "type": "string",
            "format": "date"
          },
          "due_date": {
            "type": "string",
            "format": "date"
          },
          "terms": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          },
          "tax_label": {
            "type": "string"
          },
          "tax_cents": {
            "type": "integer"
          },
          "discount_cents": {
            "type": "integer"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoiceItem"
            }
          },
          "paid": {
            "type": "object",
            "description": "Receipt mode (requires send:true): money already moved on your PSP — records the caller-attested payment, stamps PAID, sends the receipt email instead of the invoice email.",
            "properties": {
              "amount_cents": {
                "type": "integer",
                "description": "Defaults to the grand total"
              },
              "method": {
                "type": "string"
              },
              "reference": {
                "type": "string"
              }
            }
          },
          "metadata": {
            "type": "object",
            "maxProperties": 20,
            "description": "Correlation data (≤2KB serialized, scalar values) echoed in every webhook and GET; never rendered on documents."
          },
          "issuer_id": {
            "type": "integer",
            "description": "Bill under a specific issuer identity (see /api/invoices/issuers); omitted = your default issuer."
          }
        }
      },
      "SendResult": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "number": {
            "type": "string",
            "example": "INV-2026-0031"
          },
          "url": {
            "type": "string",
            "description": "The tracked link the client received"
          },
          "password": {
            "type": "string",
            "description": "Present only for clients without a PitchStation account — already emailed to them"
          },
          "test": {
            "type": "boolean"
          },
          "would_email": {
            "type": "string",
            "description": "Test mode: the address that WOULD have received the email"
          },
          "idempotent_replay": {
            "type": "boolean"
          },
          "invoice": {
            "type": "object"
          }
        }
      },
      "RefundStatus": {
        "type": "object",
        "nullable": true,
        "properties": {
          "operation_id": {
            "type": "string"
          },
          "refund_id": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "reserved",
              "pending",
              "requires_action",
              "succeeded",
              "failed",
              "canceled",
              "unknown",
              "needs_reconciliation"
            ]
          },
          "accepted": {
            "type": "boolean"
          },
          "completed": {
            "type": "boolean",
            "description": "Only settled successful, uncompensated refund"
          },
          "ok": {
            "type": "boolean",
            "description": "Compatibility alias for completed; never merely accepted"
          },
          "amount_cents": {
            "type": "integer"
          },
          "code": {
            "type": "string"
          }
        }
      }
    }
  }
}
