{
  "openapi": "3.1.0",
  "info": {
    "title": "Entendre Finance API",
    "version": "1.2.0",
    "description": "Programmatic access to crypto-native accounting and treasury operations.\n\n## Design Principles\n\n- **List-first reads** — single list endpoint with `id` query parameter for exact lookup\n- **Unified bulk writes** — `POST /transactions/update`, `POST /transactions/delete`, and `POST /transactions/mark-spam` accept an `ids` array (1-50). Journal entries support `POST /journal-entries/post`, `/reverse`, `/delete`. Legal entities support `POST /legal-entities/archive` and `POST /legal-entities/delete`.\n- **Prefixed identifiers** — every ID is type-prefixed (`txn_`, `fac_`, `je_`) for type safety\n- **Structured errors** — every error includes `code`, `message`, and `request_id`; `display_message`, `suggested_action`, and `documentation_url` are included where they aid recovery\n- **Decimal string money** — all monetary values are strings (`\"1250.00\"`), never floats\n- **ISO 8601 UTC timestamps** — `YYYY-MM-DDTHH:mm:ssZ` format\n- **Cursor-based pagination** — opaque cursors, 1-100 page size, cursors never expire\n\nFor detailed policies, see [Rate Limits](/api-reference/rate-limits), [Idempotency](/api-reference/idempotency), [Pagination](/api-reference/pagination), and [Common Objects](/api-reference/common-objects).",
    "contact": {
      "name": "Entendre Finance",
      "url": "https://entendre.finance",
      "email": "support@entendre.finance"
    },
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://api.entendre.finance/v1",
      "description": "Production — use with ek_live_ keys"
    },
    {
      "url": "https://sandbox.api.entendre.finance/v1",
      "description": "Sandbox — use with ek_test_ keys. Synthetic data, relaxed rate limits."
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Tax",
      "description": "ProConnect (Intuit) tax filing — enqueue + poll long-running returns."
    },
    {
      "name": "Sources",
      "description": "Unified read surface over the nine non-wallet/non-Plaid source collections, plus the legacy destructive + tag operations. Wallets and Plaid accounts live under Financial Accounts."
    },
    {
      "name": "Integrations",
      "description": "Read-only connection status for the fixed V1 provider catalog: accounting, Ramp cards, Plaid banking, Finch payroll, Stripe receivables, and Intuit. Raincards, Fireblocks, Gmail, Slack, and Request Finance are outside this endpoint's coverage; absence here never proves they are disconnected."
    },
    {
      "name": "OAuth Connections",
      "description": "Dashboard-internal visibility and revocation for OAuth connections made by MCP clients (Claude, ChatGPT, Cursor). Not part of the MCP tool surface."
    },
    {
      "name": "Authentication",
      "description": "API keys, OAuth 2.0 token exchange, and credential management."
    },
    {
      "name": "Organizations",
      "description": "Top-level entity associated with your API key."
    },
    {
      "name": "Legal Entities",
      "description": "Registered business entities that own accounts and hold transactions."
    },
    {
      "name": "Financial Accounts",
      "description": "Wallets, bank accounts, and exchange connections."
    },
    {
      "name": "Cards",
      "description": "Corporate spend card connections (Ramp, Rain)."
    },
    {
      "name": "Email Ingestion",
      "description": "Connect a Gmail inbox (OAuth) so incoming financial documents are ingested into the Vault, and manage the resulting connections."
    },
    {
      "name": "Transactions",
      "description": "Immutable financial events from connected accounts and cards."
    },
    {
      "name": "Ledger Accounts",
      "description": "Chart of Accounts — hierarchical tree of accounting categories."
    },
    {
      "name": "Journal Entries",
      "description": "Double-entry bookkeeping records."
    },
    {
      "name": "Balances",
      "description": "System-maintained running totals per ledger account per accounting period."
    },
    {
      "name": "Accounting Periods",
      "description": "Timeframes over which financial transactions are grouped and reported."
    },
    {
      "name": "Tags",
      "description": "Key-value labels for reporting segmentation."
    },
    {
      "name": "Jobs",
      "description": "Asynchronous background task tracking."
    },
    {
      "name": "Cost Basis",
      "description": "Cost basis methodology settings. See also the Settings group for revaluation and job configuration."
    },
    {
      "name": "Reports",
      "description": "Financial reports — realized gains, balances, reconciliation, and more."
    },
    {
      "name": "Agents",
      "description": "Autonomous AI-powered task runners that execute on a schedule with email and Slack notifications."
    },
    {
      "name": "Operational Rules",
      "description": "The copilot's persistent org-memory rules — natural-language-taught routing memory (“deposits from wallet X → account Y”) that the in-app copilot's classification engine and cash-application matcher apply on every run. Created, listed, and deleted against the SAME store the copilot uses, so rules taught here fire in copilot and vice versa."
    },
    {
      "name": "Classifications",
      "description": "Transaction-classification runs powered by the copilot's own engine — the same deterministic org-memory rule cascade, source-platform category signals, payment-account derivation, and confidence bucketing the in-app copilot uses. Identical input yields identical buckets in copilot and MCP."
    },
    {
      "name": "Webhooks",
      "description": "Real-time event subscriptions."
    },
    {
      "name": "Users",
      "description": "Authenticated user profile management."
    },
    {
      "name": "Assets",
      "description": "Digital asset lot records — cost basis tracking, FIFO/HIFO/LIFO inventory."
    },
    {
      "name": "Settings",
      "description": "Organization-level configuration — cost basis, revaluation, job scheduling."
    },
    {
      "name": "GL",
      "description": "General ledger integration — connect, configure, sync, and deduplicate entries across QuickBooks, Xero, and NetSuite."
    },
    {
      "name": "Bill Payments",
      "description": "Outgoing payments allocated across one or more bills (AP side)."
    },
    {
      "name": "API Keys",
      "description": "API key lifecycle — mint, inspect, rotate, and revoke. Dashboard-session only; keys cannot mint keys."
    },
    {
      "name": "Accruals",
      "description": "Vendor accrual entries — create, list, and reverse month-end accruals ahead of the actual bill."
    },
    {
      "name": "Activities",
      "description": "In-app activity feed items surfaced to the organization's users."
    },
    {
      "name": "Analytics",
      "description": "Pre-aggregated analytics series for dashboards — asset breakdowns, volumes, and vendor insights."
    },
    {
      "name": "AuditEvents",
      "description": "Immutable audit trail of user and system actions in the organization."
    },
    {
      "name": "Cash Applications",
      "description": "Match incoming payments to open invoices and apply cash — previews, auto-matching, and application lifecycle."
    },
    {
      "name": "Financial Insights",
      "description": "Derived financial metrics — revenue, profit, expenses, burn rate, runway, and cash flow."
    },
    {
      "name": "Notifications",
      "description": "The organization's in-app notification inbox — list notifications, inspect unread counts, mark them read or unread, and bulk-delete them. Notifications are emitted by Entendre; creation is not part of the public documented surface."
    },
    {
      "name": "Prepaid",
      "description": "Prepaid expense schedules — list recognition schedules and trigger processing."
    },
    {
      "name": "Table Groups",
      "description": "Custom user folders of assets/sources, plus a paginated grouped/flat sources feed."
    },
    {
      "name": "Treasury",
      "description": "Treasury views over cash and token positions."
    },
    {
      "name": "Vault",
      "description": "Document vault records adjacent to financial documents — bills, vendors, and bank statements ingested or uploaded for reconciliation."
    },
    {
      "name": "Events",
      "description": "Custom organization-scoped progress events published to subscribed clients."
    },
    {
      "name": "Spam Tokens",
      "description": "Per-organization token contracts classified as spam, with list, lookup, and guarded cascade operations."
    },
    {
      "name": "Whitelist Tokens",
      "description": "Per-organization token-contract allow-list entries used to override spam classification."
    }
  ],
  "paths": {
    "/tax/proconnect/filings": {
      "post": {
        "operationId": "fileProconnectReturn",
        "summary": "File a ProConnect return",
        "description": "Enqueue a ProConnect (Intuit) tax filing run for a firm client. Long-running browser automation: returns a filingId immediately (status queued); poll GET /tax/proconnect/filings/{id} until terminal. 2FA is resolved headlessly via the firm agent identity. To file from a workpaper the user provided (e.g. an attached .xlsx), read it and build one `documentTracking` row per source document — this is the same payload the Slack/copilot filing uses, so the row carries the figures (see documentTracking). With a firm-scoped API key `firmId` is optional (derived from the key); set the active client first (set_active_client) so the run is scoped correctly.",
        "tags": ["Tax"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["taskType", "clientName", "taxYear"],
                "properties": {
                  "firmId": {
                    "type": "string",
                    "description": "Optional for a firm-scoped API key (derived from the key). REQUIRED for an org-scoped key or dashboard JWT (which carry no firm). Verified against caller access either way."
                  },
                  "taskType": {
                    "type": "string",
                    "enum": ["file_workpaper", "prior_year_import"]
                  },
                  "clientName": {
                    "type": "string",
                    "description": "Client whose return to open in ProConnect (matched against the firm roster)."
                  },
                  "taxYear": {
                    "description": "Tax year of the return.",
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "number"
                      }
                    ]
                  },
                  "returnType": {
                    "type": "string",
                    "description": "ProConnect return type. Defaults to 1040."
                  },
                  "documentTracking": {
                    "type": "array",
                    "description": "Required for file_workpaper: one row PER SOURCE DOCUMENT, carrying the figures to file (this IS the filing data — the workpaper file itself is only an optional audit copy via workpaperUrl). Same shape the Slack/copilot filing uses; when the user provides a workpaper (e.g. an attached .xlsx), extract these rows from it. Each row: { issuerOrPayer, formType (e.g. \"1099-INT\", \"1099-DIV\", \"SSA-1099\"), sourceDocument, taxYear, amount, and per-box values in incomeItemsRow (a map of the workpaper's column headers to their cell values). Only include documents that should be entered.",
                    "items": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  },
                  "priorTaxYear": {
                    "description": "Required for prior_year_import: the source year.",
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "number"
                      }
                    ]
                  },
                  "priorYearAction": {
                    "type": "string",
                    "enum": ["import", "verify_imported"]
                  },
                  "allowOverwrite": {
                    "type": "boolean",
                    "description": "Prior-year import only; true only after an explicit overwrite approval."
                  },
                  "workpaperUrl": {
                    "type": "string",
                    "format": "uri",
                    "description": "HTTPS URL of the workpaper xlsx (audit copy uploaded to the client Vault)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Filing enqueued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "filingId": {
                          "type": "string"
                        },
                        "jobId": {
                          "type": ["string", "null"]
                        },
                        "status": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listProconnectFilings",
        "summary": "List ProConnect filings",
        "description": "List a firm filings, newest first. A client-org caller sees only filings pinned to their own client.",
        "tags": ["Tax"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "firmId",
            "in": "query",
            "required": false,
            "description": "Optional for a firm-scoped API key (derived from the key). Required for an org key / dashboard JWT.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["queued", "running", "needs_confirmation", "completed", "failed", "cancelled"]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Filings.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "filings": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "description": "Filing status record."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/tax/proconnect/filings/{id}": {
      "get": {
        "operationId": "getProconnectFiling",
        "summary": "Get a ProConnect filing",
        "description": "Poll one filing status + per-row results. Call across turns until status is completed, failed, or cancelled.",
        "tags": ["Tax"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Filing id."
          }
        ],
        "responses": {
          "200": {
            "description": "Filing.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "description": "Filing status record."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/tax/proconnect/filings/{id}/cancel": {
      "post": {
        "operationId": "cancelProconnectFiling",
        "summary": "Cancel a ProConnect filing",
        "description": "Cancel a queued or running ProConnect filing by id. Destructive: sets the cancel flag so an in-progress headless browser run stops at its next checkpoint; already-completed filings are unaffected.",
        "tags": ["Tax"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Filing id."
          }
        ],
        "responses": {
          "200": {
            "description": "Cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "filingId": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/bill-payments": {
      "get": {
        "operationId": "listBillPayments",
        "summary": "List bill payments",
        "description": "Lists the organization's bill payments (outgoing payments allocated across one or more bills), newest first, with page-based pagination. Filter by `bill_id`, `paid_from_account_id`, or `transaction_id` — each filter is org-scoped. Use `page` and `page_size` (max 100) to enumerate. \n\n**Payments, not bills.** This lists outgoing PAYMENTS allocated across bills — not the bills themselves. For \"show/list bills\", \"recent bills\", bill history, or bill totals, use `tools.vault.list_qbobills` for a QBO-only org (QuickBooks is the live source of truth) or `tools.vault.list_vault_bills` for uploaded/internal bill documents; on a hybrid org that uses both, check both sources. An empty result here means no payments are recorded in Entendre — it is NOT evidence the org has no bills.",
        "tags": ["Bill Payments"],
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "default": 1 },
            "description": "1-based page number."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 },
            "description": "Rows per page (max 100)."
          },
          {
            "name": "bill_id",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Filter to payments allocating to this bill (`bil_` id)."
          },
          {
            "name": "paid_from_account_id",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Filter to payments out of this financial account (`fac_` id)."
          },
          {
            "name": "transaction_id",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Filter to payments matched against this transaction (`txn_` id)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of bill payments.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "page", "page_size", "total_count", "has_more", "next_page"],
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/V1BillPayment" } },
                    "page": { "type": "integer" },
                    "page_size": { "type": "integer" },
                    "total_count": { "type": "integer" },
                    "has_more": { "type": "boolean" },
                    "next_page": { "type": ["integer", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "operationId": "createBillPayment",
        "summary": "Create a bill payment",
        "description": "Creates a bill payment that allocates a total amount across one or more bills (QuickBooks / Bill.com convention). The sum of allocations must not exceed `total_amount`, every `bill_id` must resolve in the caller's org, and per-bill cumulative allocations must not exceed the bill total — any violation returns 400. Requires a write-capable key with an owner.",
        "tags": ["Bill Payments"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["total_amount", "txn_date", "allocations"],
                "properties": {
                  "total_amount": {
                    "type": "string",
                    "description": "Total payment amount, positive decimal string (e.g. \"5000.00\")."
                  },
                  "txn_date": {
                    "type": "string",
                    "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
                    "description": "Payment date, YYYY-MM-DD."
                  },
                  "allocations": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": ["bill_id", "amount"],
                      "properties": {
                        "bill_id": {
                          "type": "string",
                          "description": "Prefixed `bil_` id of the bill this slice pays."
                        },
                        "amount": { "type": "string", "description": "Allocated amount, positive decimal string." }
                      }
                    }
                  },
                  "paid_from_account_id": {
                    "type": "string",
                    "description": "Prefixed `fac_` financial-account id the payment came from."
                  },
                  "transaction_id": {
                    "type": "string",
                    "description": "Prefixed `txn_` id to match this payment against."
                  },
                  "currency": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 8,
                    "description": "ISO currency code; defaults to USD."
                  },
                  "notes": { "type": ["string", "null"] }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Bill payment created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/V1BillPayment" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/bill-payments/{id}": {
      "get": {
        "operationId": "getBillPayment",
        "summary": "Get a bill payment",
        "description": "Gets a single bill payment by its prefixed `bp_` id, including its allocation breakdown and derived `applied_amount`. Returns 404 when the id is missing, soft-deleted, or belongs to another organization.",
        "tags": ["Bill Payments"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `bp_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The bill payment.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/V1BillPayment" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "delete": {
        "operationId": "deleteBillPayment",
        "summary": "Delete a bill payment",
        "description": "Deletes (soft-delete) a bill payment by its prefixed `bp_` id, rolling back its allocations so each affected bill's payment state is recomputed. This is irreversible at the API layer and returns 204 with no body on success. Returns 409 (with `blocked_transaction_id`) when the linked transaction has a posted journal entry — void that journal entry first.",
        "tags": ["Bill Payments"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `bp_` id."
          }
        ],
        "responses": {
          "204": { "description": "Deleted. No content." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "Linked transaction has a posted journal entry — void it first. The error body carries `blocked_transaction_id` (the `txn_` id of the blocking transaction).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "example": {
                  "error": {
                    "code": "CONFLICT",
                    "message": "Cannot delete bill_payment: linked transaction has a posted journal entry — void the journal entry first.",
                    "blocked_transaction_id": "txn_5kDsQmN1vBpLwT",
                    "request_id": "req_6rHwMpN4xKvBqT"
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api-keys": {
      "get": {
        "operationId": "listApiKeys",
        "summary": "List API keys",
        "description": "Lists every API key for the calling organization (active and revoked, newest first) with prefix, last-four, permissions, and lifecycle timestamps. Never returns plaintext or the key hash. Requires a logged-in dashboard (JWT) session — API-key callers receive 403.",
        "tags": ["API Keys"],
        "responses": {
          "200": {
            "description": "All keys for the organization.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["id", "name", "keyPrefix", "keyLastFour", "permissions", "isActive"],
                        "properties": {
                          "id": { "type": "string", "description": "Key id (bare Mongo ObjectId)." },
                          "name": { "type": "string" },
                          "keyPrefix": { "type": "string" },
                          "keyLastFour": { "type": "string" },
                          "permissions": {
                            "type": "array",
                            "items": { "type": "string" },
                            "description": "Stored permission scopes echoed verbatim. New keys carry a preset (`apis.all` or `apis.read`); legacy and auto-provisioned keys may carry other values such as `*` or `read:invoices`."
                          },
                          "isActive": { "type": "boolean" },
                          "createdAt": { "type": "string", "format": "date-time" },
                          "lastUsedAt": { "type": ["string", "null"], "format": "date-time" },
                          "usedCount": { "type": "integer", "minimum": 0 },
                          "rotatedAt": { "type": ["string", "null"], "format": "date-time" },
                          "revokedAt": { "type": ["string", "null"], "format": "date-time" },
                          "expiresAt": { "type": ["string", "null"], "format": "date-time" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      },
      "post": {
        "operationId": "createApiKey",
        "summary": "Create an API key",
        "description": "Creates a new named API key for the calling organization and returns the plaintext EXACTLY ONCE — the server stores only an HMAC hash, so it is never recoverable afterwards. The `permissions` field is either a single preset ([\"apis.all\"] All access or [\"apis.read\"] Read only) or an array of concrete v1 scopes for a restricted key (e.g. [\"read:assets\", \"write:gl\"]); it defaults to All access. Requires a logged-in dashboard (JWT) session; a duplicate `name` returns 409.",
        "tags": ["API Keys"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 64,
                    "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$",
                    "description": "Customer-facing label, unique per org."
                  },
                  "permissions": { "$ref": "#/components/schemas/ApiKeyPermissions" },
                  "expiresAt": {
                    "type": ["string", "integer", "null"],
                    "description": "Expiry as ISO 8601 string or epoch ms; null = no expiry. Must be in the future."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created. The `apiKey` plaintext is shown ONCE and never returned again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "name", "apiKey", "keyPrefix", "keyLastFour", "permissions"],
                      "properties": {
                        "id": { "type": "string", "description": "Key id (bare Mongo ObjectId)." },
                        "name": { "type": "string" },
                        "apiKey": {
                          "type": "string",
                          "description": "Full plaintext key. STORE NOW — never returned again."
                        },
                        "keyPrefix": { "type": "string" },
                        "keyLastFour": { "type": "string" },
                        "permissions": { "type": "array", "items": { "type": "string" } },
                        "expiresAt": { "type": ["string", "null"], "format": "date-time" },
                        "createdAt": { "type": "string", "format": "date-time" }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" }
        }
      }
    },
    "/api-keys/{id}/revoke": {
      "post": {
        "operationId": "revokeApiKeyById",
        "summary": "Revoke an API key by id",
        "description": "Revokes one specific API key by id, scoped to the caller's organization so a key can never be revoked across org boundaries. This is destructive and irreversible at the auth layer — the key stops authenticating immediately, while the row is retained (`isActive: false`) so the dashboard can render a Revoked badge. Requires a logged-in dashboard (JWT) session.",
        "tags": ["API Keys"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Key id (bare Mongo ObjectId)."
          }
        ],
        "responses": {
          "200": {
            "description": "Key revoked. Returns post-revoke metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "name", "keyPrefix", "keyLastFour", "isActive"],
                      "properties": {
                        "id": { "type": "string" },
                        "name": { "type": "string" },
                        "keyPrefix": { "type": "string" },
                        "keyLastFour": { "type": "string" },
                        "revokedAt": { "type": ["string", "null"], "format": "date-time" },
                        "isActive": { "type": "boolean", "description": "Always false after revoke." }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/oauth/connections": {
      "get": {
        "operationId": "listOAuthConnections",
        "x-excluded": true,
        "summary": "List active OAuth connections",
        "description": "Lists every live OAuth connection (Claude, ChatGPT, Cursor, or any other MCP client that completed the OAuth flow) for the calling organization — one row per refresh-token rotation family, regardless of how many times its access key has rotated. A family is included while its current, not-yet-rotated refresh token is unrevoked and unexpired. The access key's state is not a liveness signal because a valid refresh token can replace an expired or manually revoked access key. Requires a logged-in dashboard (JWT) session — API-key callers receive 403. Dashboard-internal: not intended for MCP tool use.",
        "tags": ["OAuth Connections"],
        "responses": {
          "200": {
            "description": "All active connections for the organization.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["familyId", "clientName", "userEmail", "connectedAt"],
                        "properties": {
                          "familyId": {
                            "type": "string",
                            "description": "Opaque id for this connection (the refresh-token family's original token hash). Pass to the revoke endpoint."
                          },
                          "clientName": {
                            "type": "string",
                            "description": "Client-supplied display name from DCR registration (e.g. \"Claude\", \"Claude Code (host)\", \"Cursor\"). Free text, not a trusted/verified identity."
                          },
                          "userEmail": { "type": "string" },
                          "connectedAt": {
                            "type": "string",
                            "format": "date-time",
                            "description": "Original authorization time, preserved across every rotation in the family."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/oauth/connections/{familyId}/revoke": {
      "post": {
        "operationId": "revokeOAuthConnection",
        "x-excluded": true,
        "summary": "Revoke an OAuth connection",
        "description": "Revokes one OAuth connection — its entire refresh-token rotation family plus the currently-active access key — scoped to the caller's organization so a connection can never be revoked across org boundaries. This is destructive and irreversible: the client is signed out immediately and must reconnect via OAuth from scratch. Requires a logged-in dashboard (JWT) session — API-key callers receive 403. Dashboard-internal: not intended for MCP tool use.",
        "tags": ["OAuth Connections"],
        "parameters": [
          {
            "name": "familyId",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" },
            "description": "The connection's familyId, as returned by GET /oauth/connections (a 64-character hex token hash)."
          }
        ],
        "responses": {
          "200": {
            "description": "Connection revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["familyId", "revokedAt"],
                      "properties": {
                        "familyId": { "type": "string" },
                        "revokedAt": { "type": "string", "format": "date-time" }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/cash-applications/unapply-all": {
      "post": {
        "operationId": "unapplyAllCashApplications",
        "summary": "Unapply all cash-applications for a deposit",
        "description": "Reverses every applied cash-application for a single deposit in one call, unposting each journal entry and returning the row to `draft`. Per-match failures are reported in `failed[]` rather than aborting the batch, and a partial success still returns 200. Returns 404 when the deposit has no applied matches and 409 when none could be unapplied.",
        "tags": ["Cash Applications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["deposit_id"],
                "properties": {
                  "deposit_id": { "type": "string", "description": "Prefixed `txn_` id of the deposit." },
                  "reason": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Optional explanation — surfaced in the webhook event only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-match unapply results.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deposit_id", "unapplied_count", "unapplied", "failed"],
                      "properties": {
                        "deposit_id": { "type": "string" },
                        "unapplied_count": { "type": "integer" },
                        "unapplied": {
                          "type": "array",
                          "items": { "type": "string" },
                          "description": "Prefixed `ca_` ids reversed."
                        },
                        "failed": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": { "id": { "type": "string" }, "reason": { "type": "string" } }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cash-applications/{id}/complete": {
      "post": {
        "operationId": "completeCashApplication",
        "summary": "Complete an applied cash-application",
        "description": "Marks an applied cash-application (`status=applied`, `stripe_status=pending`) complete by marking the matched Stripe invoice(s) paid and transitioning the row to `completed`. Idempotent on an already-completed row. Returns 400 when the row is not in the applied state, 409 on a concurrent lock, unpaid Stripe invoices, or a disconnected Stripe AR connection, and 502 on a Stripe provider error.",
        "tags": ["Cash Applications"],
        "parameters": [{ "$ref": "#/components/parameters/PathId" }],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false } } }
        },
        "responses": {
          "200": {
            "description": "Cash-application completed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/V1CashApplication" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "502": { "description": "Stripe provider error while marking the invoice paid." },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/documents/recent": {
      "get": {
        "operationId": "listRecentDocuments",
        "summary": "List recent documents",
        "tags": ["Vault"],
        "description": "List the most recently added vault documents for the org. Returns a small fixed set for previews; use `GET /vault/documents` for full filterable pagination.",
        "responses": {
          "200": {
            "description": "Recent documents.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
                    "count": { "type": "integer" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/folders/rename": {
      "post": {
        "operationId": "renameDocumentFolder",
        "summary": "Rename document folder",
        "tags": ["Vault"],
        "description": "Update (rename or consolidate) a vault document folder. Renaming onto an existing path merges only when `merge_strategy` is `merge`; protected system folders return 409.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["from_path", "to_path"],
                "properties": {
                  "from_path": { "type": "string", "minLength": 1, "maxLength": 1024 },
                  "to_path": { "type": "string", "minLength": 1, "maxLength": 1024 },
                  "merge_strategy": { "type": "string", "enum": ["merge", "error-on-collision"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rename result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/documents/move": {
      "post": {
        "operationId": "moveDocuments",
        "summary": "Move documents",
        "tags": ["Vault"],
        "description": "Bulk-move up to 500 documents into a target folder path. Collisions merge only when `merge_strategy` is `merge`, otherwise the move errors.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["document_ids", "file_directory"],
                "properties": {
                  "document_ids": { "type": "array", "maxItems": 500, "items": { "type": "string" } },
                  "file_directory": { "type": "string", "maxLength": 1024 },
                  "merge_strategy": { "type": "string", "enum": ["merge", "error-on-collision"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Move result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/documents/{id}/audit-logs": {
      "get": {
        "operationId": "listDocumentAuditLogs",
        "summary": "List document audit logs",
        "tags": ["Vault"],
        "description": "List the audit-log history for a single vault document (uploads, edits, links, deletes). Cursor pagination: pass `cursor` (or the legacy `page`) and follow `next_cursor`; `limit` (or the legacy `page_size`) caps the page (1-100, default 25).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `doc_` id."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
            "description": "Items per page (1-100, default 25). Standard alias of `page_size`."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Opaque pagination cursor from a previous response's `next_cursor`. Advances to the next page; takes precedence over `page` when both are sent."
          },
          {
            "name": "page",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1 },
            "description": "1-based page number."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
            "description": "Page size (max 100)."
          }
        ],
        "responses": {
          "200": {
            "description": "Audit-log page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
                    "users": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
                    "has_more": { "type": "boolean" },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Opaque cursor for the next page (follow it to paginate); null on the last page or when a `page_size` > 100 was requested (use `next_page` then)."
                    },
                    "next_page": { "type": ["integer", "null"] }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/upload": {
      "post": {
        "operationId": "uploadVaultDocument",
        "summary": "Upload a document",
        "tags": ["Vault"],
        "description": "Upload a single document as base64. The base64 string is capped at 14 MB, i.e. ~10.5 MB of decoded file content. Returns the created document id plus an extracted summary; the file is classified into a document type server-side.\n\nThis is a write that persists a document to the Vault. Call it only when the user explicitly asks to upload a document — do NOT call it just because a file was mentioned or attached.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["filename", "base64"],
                "properties": {
                  "filename": { "type": "string", "minLength": 1, "maxLength": 255 },
                  "base64": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Base64-encoded file contents. The base64 string is capped at 14 MB (~10.5 MB decoded)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Uploaded document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/bills/reconcile": {
      "post": {
        "operationId": "reconcileBill",
        "summary": "Reconcile a bill",
        "tags": ["Vault"],
        "description": "Apply reconciliation links (matched bill, vendor, amount) to a vault bill document. All linking fields are optional so partial reconciliation metadata can be attached incrementally.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "document_id": { "type": "string" },
                  "bill_id": { "type": "string" },
                  "vendor_id": { "type": "string" },
                  "amount": { "type": "number", "minimum": 0 },
                  "bill_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
                  "notes": { "type": "string", "maxLength": 2000 },
                  "legal_entity_id": { "type": "string" },
                  "legal_entity_name": { "type": "string", "maxLength": 256 }
                }
              }
            }
          }
        },
        "responses": {
          "409": { "$ref": "#/components/responses/Conflict" },
          "200": {
            "description": "Reconciliation result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/bills/{id}": {
      "patch": {
        "operationId": "updateBill",
        "summary": "Update a bill",
        "tags": ["Vault"],
        "description": "Update the editable metadata on a vault bill document (number, dates, currency, amounts, tax, notes). The path id is the document id (`doc_`); any field may be null to clear it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `doc_` id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "bill_number": { "type": ["string", "null"], "maxLength": 128 },
                  "bill_date": { "type": ["string", "null"], "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
                  "due_date": { "type": ["string", "null"], "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
                  "billing_period_start": { "type": ["string", "null"], "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
                  "billing_period_end": { "type": ["string", "null"], "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
                  "currency": { "type": ["string", "null"], "maxLength": 8 },
                  "total_amount": { "type": ["string", "null"], "description": "Decimal string." },
                  "tax_amount": { "type": ["string", "null"], "description": "Decimal string." },
                  "tax_rate": { "type": ["string", "null"], "description": "Decimal string." },
                  "tax_type": { "type": ["string", "null"], "maxLength": 64 },
                  "notes": { "type": ["string", "null"], "maxLength": 2000 }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated bill.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/vendors/sync": {
      "post": {
        "operationId": "syncVendors",
        "summary": "Sync vendors from GL",
        "tags": ["Vault"],
        "description": "Synchronize vendors from a connected GL provider into the vault. Pass `gl_type` (QUICKBOOKS, XERO, NETSUITE, DUALENTRY, CAMPFIRE); `realm_id` is required only when the org has multiple auth records of that type (else a 409 lists the realms).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["gl_type"],
                "properties": {
                  "gl_type": {
                    "type": "string",
                    "enum": ["QUICKBOOKS", "XERO", "NETSUITE", "DUALENTRY", "CAMPFIRE"],
                    "description": "Case-insensitive; normalized to uppercase."
                  },
                  "realm_id": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sync result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/vendors/{id}/merge": {
      "post": {
        "operationId": "mergeVendor",
        "summary": "Merge a vendor",
        "tags": ["Vault"],
        "description": "Remove a duplicate vendor by merging it into the path vendor, repointing the merged vendor's references. The surviving vendor is the path id; `vendor_id_to_merge` is absorbed and removed.\n\n**DESTRUCTIVE and irreversible** — the merged vendor is permanently removed and its bills/accruals/mappings are repointed. Call this ONLY when the user explicitly asks to merge vendors; first show the exact `id` (survivor) + `vendor_id_to_merge` payload and wait for their confirmation. Both ids come from `analytics.get_vendor_insights` or the vendor list.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `vnd_` id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["vendor_id_to_merge"],
                "properties": {
                  "vendor_id_to_merge": {
                    "type": "string",
                    "description": "Prefixed `vnd_` id to merge into the path vendor."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Merged vendor.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/vendors/{id}": {
      "get": {
        "operationId": "getVendor",
        "summary": "Get a vendor",
        "tags": ["Vault"],
        "description": "Get a single vendor by its prefixed `vnd_` id. Returns 404 if the vendor does not exist or belongs to another org.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `vnd_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The vendor.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "patch": {
        "operationId": "updateVendor",
        "summary": "Update a vendor",
        "tags": ["Vault"],
        "description": "Update a vendor's default expense-account mapping. Pass a prefixed `lac_` id to map the vendor to that account, or null to clear the mapping.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `vnd_` id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "default_expense_account_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `lac_` id, or null to clear."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated vendor.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/statements": {
      "get": {
        "operationId": "listBankStatements",
        "summary": "List bank statements",
        "tags": ["Vault"],
        "description": "List imported bank statements for the org. Cursor pagination: pass `cursor` (or the legacy `page`) and follow the response's `next_cursor`; `limit` (or the legacy `page_size`) caps the page (1-100, default 25). Filter by reconciliation status.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
            "description": "Items per page (1-100, default 25). Standard alias of `page_size`."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Opaque pagination cursor from a previous response's `next_cursor`. Advances to the next page; takes precedence over `page` when both are sent."
          },
          {
            "name": "page",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1 },
            "description": "1-based page number."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
            "description": "Page size (max 100)."
          },
          {
            "name": "status",
            "in": "query",
            "schema": { "type": "string", "enum": ["reconciled", "not_reconciled"] },
            "description": "Filter by reconciliation status."
          }
        ],
        "responses": {
          "200": {
            "description": "Statements page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
                    "has_more": { "type": "boolean" },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Opaque cursor for the next page (follow it to paginate); null on the last page or when a `page_size` > 100 was requested (use `next_page` then)."
                    },
                    "next_page": { "type": ["integer", "null"] },
                    "total_count": { "type": ["integer", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/statements/{id}": {
      "get": {
        "operationId": "getBankStatement",
        "summary": "Get a bank statement",
        "tags": ["Vault"],
        "description": "Get a single bank statement by its prefixed `bnk_` id. Returns 404 if the statement does not exist or belongs to another org.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `bnk_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The statement.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/statement-lines": {
      "get": {
        "operationId": "listStatementLines",
        "summary": "List statement lines",
        "tags": ["Vault"],
        "description": "List the lines of a bank statement (the canonical reconciliation unit). Requires `statement_id`; optionally filter by import status or reconciliation state. Cursor pagination: pass `cursor` (or the legacy `page`) and follow `next_cursor`; `limit` (1-100) is the standard alias of the legacy `page_size`. The effective page is capped at 100 rows, so `next_cursor` is always emitted and round-trips; an explicit `page_size` up to 500 is accepted but still returns at most 100 rows per page.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
            "description": "Rows per page (1-100). Standard alias of `page_size`; when neither `limit` nor `page_size` is sent the page defaults to 100 (so `next_cursor` is emitted)."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Opaque pagination cursor from a previous response's `next_cursor`. Advances to the next page; takes precedence over `page` when both are sent."
          },
          {
            "name": "statement_id",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `bnk_` statement id."
          },
          {
            "name": "import_status",
            "in": "query",
            "schema": { "type": "string", "enum": ["pending", "imported", "ignored", "duplicate"] },
            "description": "Filter by import status."
          },
          {
            "name": "is_reconciled",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Filter by reconciliation state."
          },
          {
            "name": "page",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1 },
            "description": "1-based page number (default 1)."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 500 },
            "description": "Legacy offset page size (max 500). When neither this nor `limit` is sent the page defaults to 100. The effective page is capped at 100 rows even if a larger value is passed, so `next_cursor` always round-trips."
          }
        ],
        "responses": {
          "200": {
            "description": "Statement lines (one page).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
                    "count": {
                      "type": "integer",
                      "description": "TOTAL statement lines matching the filter (all pages), not just this page. `data` holds one page of `page_size`."
                    },
                    "statement_id": { "type": "string" },
                    "has_more": { "type": "boolean" },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Opaque cursor for the next page (follow it to paginate); null on the last page or when a `page_size` > 100 was requested (use `next_page` then)."
                    },
                    "next_page": { "type": ["integer", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/statement-lines/{id}/match": {
      "post": {
        "operationId": "matchStatementLine",
        "summary": "Match a statement line",
        "tags": ["Vault"],
        "description": "Connect a single statement line to a transaction. The path id is the statement-line id (`bkl_`); the body carries the `txn_` id to link.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `bkl_` id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["transaction_id"],
                "properties": { "transaction_id": { "type": "string", "description": "Prefixed `txn_` id." } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Match result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/vault/statement-lines/{id}/unmatch": {
      "post": {
        "operationId": "unmatchStatementLine",
        "summary": "Unmatch a statement line",
        "tags": ["Vault"],
        "description": "Remove the transaction match from a single statement line. The path id is the statement-line id (`bkl_`); no body is required.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `bkl_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "Unmatch result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/asset-breakdown": {
      "get": {
        "operationId": "getAnalyticsAssetBreakdown",
        "summary": "Asset breakdown",
        "tags": ["Analytics"],
        "description": "Returns net positions by asset type AS OF a date (a point-in-time snapshot, not a flow over a window) — the same cost-basis engine behind `get_closing_positions_report`. Quantities are the booked net of acquisitions minus disposals from the cost-basis subledger; they reflect the accounting ledger, which can differ from live on-chain wallet balances (to reconcile against chain, use `get_balance_reconciliation`). Values are marked at the as-of date. Fiat (e.g. USD) is excluded unless you request it explicitly via `asset_types`; stablecoins like USDC are included. The snapshot date is `end_date` if given, else the LATEST end date among `accounting_period_ids`, else now; `start_date` is accepted but ignored. `accounting_period_ids` only selects the as-of date (it is NOT a per-period filter — positions include all activity up to that date), and `end_date` takes precedence over it. Filterable by legal entity and asset type.",
        "parameters": [
          {
            "name": "start_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "Accepted for back-compat but IGNORED — this is a point-in-time holdings snapshot, not a windowed flow."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; the snapshot is taken AS OF this date (holdings up to and including it). Omit for current holdings."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Filter by legal entity; repeat the param (e.g. `?legal_entity_ids=le_1&legal_entity_ids=le_2`). Prefixed `le_` ids (or bare). Max 50."
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Comma-separated asset types (e.g. `?asset_types=ETH,USDC`). Max 50."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Sets the snapshot as-of date to the LATEST end date among these periods (NOT a per-period filter — positions include all activity up to that date). Ignored when `end_date` is given. Returns 400 if none resolve to a period in this org. Repeat the param. Prefixed `ap_` ids (or bare). Max 50."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "additionalProperties": true,
                      "description": "Analytics payload (chart / series object)."
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/transactions-volume": {
      "get": {
        "operationId": "getAnalyticsTransactionsVolume",
        "summary": "Transactions volume",
        "tags": ["Analytics"],
        "description": "Get transaction-volume chart data over a time window, with the resolved window metadata returned alongside the series. Filterable by legal entity, asset type, and chain; spam is excluded unless `include_spam=true`.",
        "parameters": [
          {
            "name": "start_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive lower bound."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive upper bound. When both bounds are given the window must be <= 366 days."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Filter by legal entity; repeat the param (e.g. `?legal_entity_ids=le_1&legal_entity_ids=le_2`). Prefixed `le_` ids (or bare). Max 50."
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Comma-separated asset types (e.g. `?asset_types=ETH,USDC`). Max 50."
          },
          {
            "name": "chains",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Comma-separated chains (e.g. `?chains=ethereum,base`). Max 20."
          },
          {
            "name": "include_spam",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Include spam-flagged transactions (excluded by default)."
          },
          {
            "name": "spam_only",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Return only spam-flagged transactions."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "chart": {
                          "type": "array",
                          "description": "Daily volume series, sorted ascending by date. Empty array when the window has no transactions.",
                          "items": {
                            "type": "object",
                            "additionalProperties": true,
                            "properties": {
                              "Gross Amount": { "type": "number" },
                              "Net Amount": { "type": "number" },
                              "Fee": { "type": "number" },
                              "Date": { "type": "string", "description": "MM/dd/yyyy" }
                            }
                          }
                        },
                        "window": {
                          "type": "object",
                          "properties": {
                            "start_date": { "type": "string" },
                            "end_date": { "type": "string" },
                            "max_window_days": { "type": "integer" },
                            "start_date_capped": { "type": "boolean" }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/net-fiat-cumulative": {
      "get": {
        "operationId": "getAnalyticsNetFiatCumulative",
        "summary": "Net fiat cumulative",
        "tags": ["Analytics"],
        "description": "Get the cumulative net fiat flow series over a time window. Filterable by legal entity, asset type, and chain; spam is excluded unless `include_spam=true`.",
        "parameters": [
          {
            "name": "start_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive lower bound."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive upper bound. When both bounds are given the window must be <= 366 days."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Filter by legal entity; repeat the param (e.g. `?legal_entity_ids=le_1&legal_entity_ids=le_2`). Prefixed `le_` ids (or bare). Max 50."
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Comma-separated asset types (e.g. `?asset_types=ETH,USDC`). Max 50."
          },
          {
            "name": "chains",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Comma-separated chains (e.g. `?chains=ethereum,base`). Max 20."
          },
          {
            "name": "include_spam",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Include spam-flagged transactions (excluded by default)."
          },
          {
            "name": "spam_only",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Return only spam-flagged transactions."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "series": { "type": "array", "items": { "type": "object", "additionalProperties": true } }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/asset-quantity-changes": {
      "get": {
        "operationId": "getAnalyticsAssetQuantityChanges",
        "summary": "Asset quantity changes",
        "tags": ["Analytics"],
        "description": "Get per-asset quantity changes across the requested date window. Both `start_date` and `end_date` are required and the window is capped at 366 days.",
        "parameters": [
          {
            "name": "start_date",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive lower bound (required)."
          },
          {
            "name": "end_date",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive upper bound (required). The window must be <= 366 days."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "changes": { "type": "array", "items": { "type": "object", "additionalProperties": true } }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/transactions-by-source": {
      "get": {
        "operationId": "getAnalyticsTransactionsBySource",
        "summary": "Transactions by source",
        "tags": ["Analytics"],
        "description": "Get transaction totals grouped by source. This endpoint currently filters only by `chains` and `legal_entity_ids`; the other transaction filters are accepted for call-shape uniformity but not yet applied.",
        "parameters": [
          {
            "name": "legal_entity_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Filter by legal entity; repeat the param (e.g. `?legal_entity_ids=le_1&legal_entity_ids=le_2`). Prefixed `le_` ids (or bare). Max 50."
          },
          {
            "name": "chains",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Comma-separated chains (e.g. `?chains=ethereum,base`). Max 20."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "Accepted for call-shape uniformity with the other transaction analytics, but currently NOT applied by this endpoint (only `chains` and `legal_entity_ids` filter the result)."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "Accepted for call-shape uniformity with the other transaction analytics, but currently NOT applied by this endpoint (only `chains` and `legal_entity_ids` filter the result)."
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Accepted for call-shape uniformity with the other transaction analytics, but currently NOT applied by this endpoint (only `chains` and `legal_entity_ids` filter the result)."
          },
          {
            "name": "include_spam",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Accepted for call-shape uniformity with the other transaction analytics, but currently NOT applied by this endpoint (only `chains` and `legal_entity_ids` filter the result)."
          },
          {
            "name": "spam_only",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Accepted for call-shape uniformity with the other transaction analytics, but currently NOT applied by this endpoint (only `chains` and `legal_entity_ids` filter the result)."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "additionalProperties": true,
                      "description": "Analytics payload (chart / series object)."
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/historical-price": {
      "get": {
        "operationId": "getAnalyticsHistoricalPrice",
        "summary": "Historical price",
        "tags": ["Analytics"],
        "description": "Get the historical price series for a single asset type across the requested date window. `asset_type`, `start_date`, and `end_date` are all required.",
        "parameters": [
          {
            "name": "asset_type",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Asset type / symbol (e.g. `ETH`). Note: the parameter is `asset_type`, not `asset`."
          },
          {
            "name": "start_date",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive lower bound (required)."
          },
          {
            "name": "end_date",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO timestamp; inclusive upper bound (required). The window must be <= 366 days."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "additionalProperties": true,
                      "description": "Analytics payload (chart / series object)."
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/analytics/income-expense-series": {
      "get": {
        "operationId": "getAnalyticsIncomeExpenseSeries",
        "summary": "Income / expense series",
        "tags": ["Analytics"],
        "description": "Get the income-statement and balance-sheet series by accounting period and ledger account, derived from posted journal entries. Computed LIVE from the same income-statement/balance-sheet engine the reports endpoints use, so `is_stale` is always `false` and `computed_at` is the request time (the fields are retained for envelope stability). Optionally scope to specific accounting periods and/or legal entities; with no `accounting_period_ids` the full series (all of the org's periods) is returned. Per-ledger-account amounts are the period's `currentBalance` (period movement for income/expense; opening + movement cumulative for asset/liability/equity). Only LEAF (postable) accounts are included; rolled-up parent accounts are omitted so summing a bucket does not double-count. `income_statement`/`balance_sheet` are keyed by period name (e.g. `JAN 2026`) → account type (`Income`/`Expense` for income_statement; `Asset`/`Liability`/`Equity` for balance_sheet) → prefixed `lac_` id. Join to `accounting_periods` by period name and to `ledger_accounts` by the `lac_` id.",
        "parameters": [
          {
            "name": "accounting_period_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Filter by accounting period; repeat the param. Prefixed `ap_` ids (or bare). Max 50."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Scope the series to specific legal entities; repeat the param. Prefixed `le_` ids (or bare). Max 50. Omit for an org-wide series (periods rolled up by month across entities)."
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "is_stale": { "type": "boolean" },
                        "computed_at": { "type": ["string", "null"] },
                        "accounting_periods": {
                          "type": "array",
                          "items": { "type": "object", "additionalProperties": true }
                        },
                        "income_statement": { "type": ["object", "null"], "additionalProperties": true },
                        "balance_sheet": { "type": ["object", "null"], "additionalProperties": true },
                        "ledger_accounts": {
                          "type": "array",
                          "items": { "type": "object", "additionalProperties": true }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/connections": {
      "get": {
        "operationId": "getCardConnection",
        "summary": "Get Ramp connection",
        "tags": ["Cards"],
        "description": "Get the org's Ramp connection status and mapping. Returns the connection flags (`is_connected`, `has_credentials`), the mapped legal entity, and the card / bill payment ledger accounts.",
        "responses": {
          "200": {
            "description": "The Ramp connection.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/RampConnection" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "operationId": "createCardConnection",
        "summary": "Connect Ramp",
        "tags": ["Cards"],
        "description": "Connect the org to Ramp by exchanging the OAuth authorization `code` (bound to the `state` issued by `/cards/connect/oauth`). Returns the resulting connection.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["code", "state"],
                "properties": {
                  "code": { "type": "string", "minLength": 1, "description": "OAuth authorization code from Ramp." },
                  "state": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Encrypted state issued by `/v1/cards/connect/oauth`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Connected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/RampConnection" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "delete": {
        "operationId": "deleteCardConnection",
        "summary": "Disconnect Ramp",
        "tags": ["Cards"],
        "description": "Disconnect the org's Ramp integration. Removes the OAuth tokens (`accessToken`/`refreshToken`) from the connection row; the row itself — including `clientId`, `clientSecret`, the legal-entity, and ledger mappings — is preserved so a later reconnect (POST /cards/connections) can reattach without re-entering configuration. Cards already imported are retained. Returns 204 with no body.",
        "responses": {
          "404": { "$ref": "#/components/responses/NotFound" },
          "204": { "description": "Disconnected." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/connections/legal-entity": {
      "patch": {
        "operationId": "updateCardConnectionLegalEntity",
        "summary": "Set connection legal entity",
        "tags": ["Cards"],
        "description": "Update the legal entity the Ramp connection maps to. Pass a prefixed `le_` id, or null to clear the mapping.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["legal_entity_id"],
                "properties": {
                  "legal_entity_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `le_` id, or null to clear."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated connection.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/RampConnection" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/connections/credentials": {
      "post": {
        "operationId": "setCardCredentials",
        "summary": "Set Ramp credentials",
        "tags": ["Cards"],
        "description": "Set the Ramp API client credentials for the org's connection. The `client_id` and `client_secret` are encrypted at rest and never returned.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["client_id", "client_secret"],
                "properties": {
                  "client_id": { "type": "string", "minLength": 1, "maxLength": 512 },
                  "client_secret": { "type": "string", "minLength": 1, "maxLength": 2048 }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Credentials stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/RampConnection" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/bulk": {
      "delete": {
        "operationId": "deleteCards",
        "summary": "Bulk-delete cards",
        "tags": ["Cards"],
        "description": "Delete up to 5000 Ramp cards by prefixed `crd_` id. Every id must belong to the caller's org; if any id is unknown or foreign the call returns 404 and deletes nothing. The response echoes the deleted ids and count.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["card_ids"],
                "properties": {
                  "card_ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5000,
                    "items": { "type": "string", "description": "Card id (`crd_<id>` or bare)." }
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the number of distinct card ids in the request, otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "404": { "$ref": "#/components/responses/NotFound" },
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted_count", "deleted_card_ids"],
                      "properties": {
                        "deleted_count": { "type": "integer" },
                        "deleted_card_ids": { "type": "array", "items": { "type": "string" } }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/merchants": {
      "get": {
        "operationId": "listCardMerchants",
        "summary": "List card merchants",
        "tags": ["Cards"],
        "description": "List the distinct merchant names seen across the org's Ramp card transactions. Offset-paginated via `page` / `page_size` (max 500).",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1 },
            "description": "1-based page number."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 500 },
            "description": "Page size (default 100, max 500)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated merchant names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "type": "object", "properties": { "name": { "type": "string" } } }
                    },
                    "count": { "type": "integer" },
                    "total_count": { "type": "integer" },
                    "has_more": { "type": "boolean" },
                    "next_page": { "type": ["integer", "null"] }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/bank-accounts": {
      "get": {
        "operationId": "listCardBankAccounts",
        "summary": "List Ramp bank accounts",
        "tags": ["Cards"],
        "description": "List the org's Ramp-linked bank accounts. Set `include_merged=true` to also return accounts that have been merged into a Plaid source.",
        "parameters": [
          {
            "name": "include_merged",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Include accounts merged into a Plaid source (hidden by default)."
          }
        ],
        "responses": {
          "200": {
            "description": "Bank accounts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/RampBankAccount" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/bank-accounts/{id}": {
      "get": {
        "operationId": "getCardBankAccount",
        "summary": "Get a Ramp bank account",
        "tags": ["Cards"],
        "description": "Get a single Ramp bank account by its prefixed `rbk_` id. Returns 404 if the account does not exist or belongs to another org.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Bank account id (`rbk_<id>`)."
          }
        ],
        "responses": {
          "200": {
            "description": "The bank account.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/RampBankAccount" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/payment-accounts": {
      "get": {
        "operationId": "getCardPaymentAccounts",
        "summary": "Get payment-account mapping",
        "tags": ["Cards"],
        "description": "Get the org-level card and bill payment ledger-account mapping. This is a singleton resource (one mapping per org), not keyed by card.",
        "responses": {
          "200": {
            "description": "The mapping.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/CardPaymentAccountConfig" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "patch": {
        "operationId": "updateCardPaymentAccounts",
        "summary": "Update payment-account mapping",
        "tags": ["Cards"],
        "description": "Update the org-level card and bill payment ledger-account mapping. Provide either field as a prefixed `lac_` id, or null to clear it.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "card_payment_ledger_account_id": { "type": ["string", "null"] },
                  "bill_payment_ledger_account_id": { "type": ["string", "null"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated mapping.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/CardPaymentAccountConfig" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cards/raincard-payment-accounts": {
      "get": {
        "operationId": "getRaincardPaymentAccounts",
        "summary": "Get Raincard payment mapping",
        "tags": ["Cards"],
        "description": "Get the Raincard card/bill payment ledger-account mapping for the org. Read-only singleton resource mirroring the Ramp payment-accounts shape.",
        "responses": {
          "200": {
            "description": "The mapping.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/CardPaymentAccountConfig" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources": {
      "get": {
        "operationId": "listSources",
        "summary": "List sources",
        "x-mcp-no-entity-scope": true,
        "tags": ["Sources"],
        "description": "List, in one unified read, the nine source collections (exchange, staking, niural, request-finance, credit-coop, raincard, ramp card, ramp bank account, manual bank). Each row is an envelope (`id`, `source_type`, `legal_entity_id`, `name`, `status`, timestamps) plus a `detail` object whose keys vary by `source_type`. Wallets and Plaid accounts are NOT here \u2014 fetch those from `GET /financial-accounts`. Reverse-chronological keyset pagination. To resolve a source by NAME, search BOTH this endpoint and `GET /financial-accounts` \u2014 the inventory is split across the two. Then: exactly 1 match \u2192 use its id; 2+ matches \u2192 ask the user which one; 0 matches \u2192 list what exists rather than claim the source is not connected. This endpoint is the long-tail feed ONLY \u2014 it is NOT the complete source inventory, so a bare row count here UNDERCOUNTS the org's raw V1 inventory. On an unfiltered first page, `total_source_count` may carry the raw V1 inventory union (financial accounts plus long-tail sources, exchange de-duplicated). That raw count includes external wallet-address records, so it is not a transaction-importing or Copilot connected-source total. Never report a bare count from this endpoint, or a truncated or failed page, as a definitive count for a user-defined source population.\n\nAsset-vs-liability derivation (this feed drives downstream ledger classification): a Plaid credit account is a credit card so liability; a Plaid depository account is a bank so asset (the default when it has no ledger mapping); other Plaid types (loan, investment) are banks but must NOT be forced to asset: keep the mapped ledger account type (a loan is a liability, so defaulting it to asset would misclassify it). Wallets and exchanges are assets.",
        "parameters": [
          {
            "name": "legal_entity_id",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Filter to a single legal entity (`le_<id>` or a bare id)."
          },
          {
            "name": "source_types",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "exchange",
                  "staking",
                  "niural",
                  "request_finance",
                  "credit_coop",
                  "raincard",
                  "ramp_card",
                  "ramp_bank_account",
                  "manual_bank"
                ]
              }
            },
            "description": "Filter to one or more source types (repeat the param, e.g. `?source_types=exchange&source_types=staking`)."
          },
          {
            "name": "source_ids",
            "in": "query",
            "required": false,
            "description": "Comma-joined source ids. Returns only these sources, across every in-scope collection \u2014 the source-side mirror of `financial_account_ids` on `/v1/financial-accounts`. Accepts the prefixed id form as well as a bare id. A cursor is pinned to the `source_ids` set it was issued for, so a page cannot be replayed against a different one.",
            "schema": { "type": "string" }
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "When `true`, includes a summed `total_count` in the response. Opt-in \u2014 counting is skipped by default to keep list calls cheap."
          },
          { "$ref": "#/components/parameters/LimitQuery" },
          { "$ref": "#/components/parameters/CursorQuery" }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of sources.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Source" } },
                    "has_more": { "type": "boolean", "description": "`true` if more pages exist." },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` for the next page. `null` on the last page."
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Summed total across all matched source types (long-tail /v1/sources collections ONLY — NOT the org's full source inventory). Present only when `include_count=true`. `total_source_count` and GET /v1/sources/connected-count provide the broader raw V1 inventory union, including external wallet-address records."
                    },
                    "total_source_count": {
                      "type": "integer",
                      "description": "The raw V1 inventory union — financial-account records (wallets, exchanges, and Plaid accounts) plus long-tail sources. It includes external wallet-address records, so it is not a transaction-importing or Copilot connected-source total. Present only on the FIRST page (no `cursor`), on an UNFILTERED request (no `source_types` / `legal_entity_id`), and when the API key can read financial-accounts; omitted otherwise. Matches GET /v1/sources/connected-count's `total`."
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/exchange": {
      "delete": {
        "operationId": "bulkDeleteExchangeSources",
        "summary": "Bulk-delete exchange sources",
        "tags": ["Sources"],
        "description": "Delete up to 50 exchange sources by id. Ids not owned by the caller's org are silently skipped (success-with-zero, not 404). Blocked with 409 if any source has related journal entries \u2014 void them first.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 50,
                    "items": { "type": "string", "description": "Exchange source id (`exs_<id>` or bare)." }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Count of deleted sources.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted_count"],
                      "properties": { "deleted_count": { "type": "integer" } }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/exchange/tags": {
      "post": {
        "operationId": "tagExchangeSources",
        "summary": "Tag exchange sources",
        "tags": ["Sources"],
        "description": "Tag up to 50 exchange sources with one tag. `applied_count` is how many were newly tagged (already-tagged sources are not double-counted); `applied` is `true` when that count is > 0.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["source_ids", "tag_id"],
                "properties": {
                  "source_ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 50,
                    "items": { "type": "string", "description": "Exchange source id (`exs_<id>` or bare)." }
                  },
                  "tag_id": { "type": "string", "description": "Tag id (`tag_<id>` or bare)." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tag apply result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["source_ids", "tag_id", "applied", "applied_count"],
                      "properties": {
                        "source_ids": { "type": "array", "items": { "type": "string" } },
                        "tag_id": { "type": "string" },
                        "applied": { "type": "boolean" },
                        "applied_count": { "type": "integer" }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/exchange/{id}": {
      "delete": {
        "operationId": "deleteExchangeSource",
        "summary": "Delete an exchange source",
        "tags": ["Sources"],
        "description": "Delete a single exchange source. Blocked with 409 if related journal entries exist (`blocking_journal_entry_count` is returned) \u2014 void them first.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Exchange source id (`exs_<id>`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": { "id": { "type": "string" }, "deleted": { "type": "boolean" } }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/request-finance/{id}": {
      "delete": {
        "operationId": "deleteRequestFinanceSource",
        "summary": "Delete a request-finance source",
        "tags": ["Sources"],
        "description": "Delete a single request-finance source. Blocked with 409 if the source has transactions with accounting.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Request-finance source id (`rfs_<id>`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": { "id": { "type": "string" }, "deleted": { "type": "boolean" } }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/credit-coop/{id}": {
      "delete": {
        "operationId": "deleteCreditCoopSource",
        "summary": "Delete a credit-coop source",
        "tags": ["Sources"],
        "description": "Delete a single credit-coop source by its `ccs_` id. Blocked with 409 if the source has posted journal entries that would be orphaned.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Credit-coop source id (`ccs_<id>`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": { "id": { "type": "string" }, "deleted": { "type": "boolean" } }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/niural/{id}": {
      "delete": {
        "operationId": "deleteNiuralSource",
        "summary": "Delete a niural source",
        "tags": ["Sources"],
        "description": "Soft-delete a single niural source. Blocked with 409 if the source has any non-draft invoices (`blocking_invoice_count` is returned).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Niural source id (`nis_<id>`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": { "id": { "type": "string" }, "deleted": { "type": "boolean" } }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/connected-count": {
      "get": {
        "operationId": "getConnectedSourceCount",
        "summary": "Count V1 source/account inventory records",
        "tags": ["Sources"],
        "description": "Returns a raw V1 storage-inventory count: financial-account records (wallets, exchanges, and Plaid accounts) plus long-tail `/sources` records, with `source_type: exchange` excluded from the long-tail to avoid double-counting exchanges. Wallet records of BOTH `internal` and `external` type are included. External wallets are counterparty/address labels and do not import activity, so this total is NOT the count of transaction-importing sources. To derive an importing-source count, enumerate `/financial-accounts` and count only wallet rows with `details.wallet_type: internal`, then add exchanges, bank/card accounts, and non-exchange long-tail sources. Returns `{ data: { summary: { financial_accounts, long_tail_sources }, total } }`.",
        "responses": {
          "200": {
            "description": "Connected source/account counts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["summary", "total"],
                      "properties": {
                        "summary": {
                          "type": "object",
                          "required": ["financial_accounts", "long_tail_sources"],
                          "properties": {
                            "financial_accounts": { "type": "integer" },
                            "long_tail_sources": { "type": "integer" }
                          }
                        },
                        "total": { "type": "integer" }
                      }
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/sources/{id}": {
      "get": {
        "operationId": "getSource",
        "summary": "Get a source",
        "tags": ["Sources"],
        "description": "Get a single source by its prefixed id. The prefix selects the collection: `exs_` exchange, `sts_` staking, `nis_` niural, `rfs_` request-finance, `ccs_` credit-coop, `rnc_` raincard, `crd_` ramp card, `rbk_` ramp bank account, `mba_` manual bank.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed source id."
          }
        ],
        "responses": {
          "200": {
            "description": "The source.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/Source" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/accruals": {
      "get": {
        "operationId": "listAccruals",
        "summary": "List vendor accruals",
        "description": "Lists the organization's vendor accruals. Filter by `vendor_id` (`vnd_<id>`), `period` (YYYY-MM), and `status`.",
        "tags": ["Accruals"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "vendor_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter to a single vendor (`vnd_<id>` or a bare id)."
          },
          {
            "name": "period",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}$"
            },
            "description": "Accrual period, YYYY-MM."
          },
          {
            "name": "status",
            "in": "query",
            "schema": { "type": "string", "enum": ["pending", "posted", "reversed", "matched", "all"] },
            "description": "Status filter (default `posted`). `all` skips the status filter."
          }
        ],
        "responses": {
          "200": {
            "description": "List of accruals.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Accrual"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createAccruals",
        "summary": "Create vendor accruals",
        "description": "Bulk-creates vendor accruals. Each entry is processed independently — a single failure does not roll back the others; the response carries `created` + `failed` arrays so callers can retry just the failures. A request records an internal accrual and does not prove an external provider bill is absent; callers must check their bill source before creating a duplicate accrual.",
        "tags": ["Accruals"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AccrualCreateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-entry create results.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AccrualCreateResult"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation error."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accruals/reverse": {
      "post": {
        "operationId": "reverseAccrual",
        "summary": "Reverse a vendor accrual",
        "description": "Reverses a posted vendor accrual by booking a linked compensating reversal journal entry (debits and credits swapped): the original entry is marked REVERSED and points to the new reversal entry. Provide exactly one of `accrual_id` or `journal_sequence_number` to identify the accrual to reverse.",
        "tags": ["Accruals"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AccrualReverseRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reversal result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AccrualReverseResult"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation error."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Accrual not found."
          },
          "409": {
            "description": "Accrual already reversed."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/analytics/missing-bills": {
      "get": {
        "operationId": "getMissingBills",
        "summary": "Missing bills for a period",
        "description": "Lists vendors expected to bill in `period` whose bill has not yet arrived. Choose the source with `bill_source`: the REST default is `vault`, while `auto` selects from org topology (a connected Ramp uses vault bills; otherwise a QuickBooks GL connection routes to the QBO API) — matching the in-app copilot. This MCP tool defaults to `auto`. Pass `bill_source=qbo` to force the QuickBooks API or `bill_source=vault` to force vault. This surfaces MISSING bills (accrual candidates) — an existing QBO bill is never a missing-bill candidate, so for the actual QBO bill history use `tools.vault.list_qbobills`. (Mirrors copilot vendor-accruals-tools.ts:1362.)",
        "tags": ["Analytics"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-(0[1-9]|1[0-2])$"
            },
            "description": "YYYY-MM."
          },
          {
            "name": "confidence_threshold",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "description": "Confidence floor (default 90)."
          },
          {
            "name": "min_amount",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Minimum expected amount."
          },
          {
            "name": "use_realtime",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Real-time scan (default true) vs cached pattern."
          },
          {
            "name": "bill_source",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["vault", "qbo", "auto"]
            },
            "x-mcp-wrapper-default": "auto",
            "description": "Which source answers 'what bills are missing?'. Omit for `vault` (the default). `qbo` queries the connected QuickBooks API. `auto` selects from org topology (Ramp → vault; QuickBooks GL → qbo), matching the copilot. This tool defaults to `auto` when you omit it."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
            },
            "description": "ISO date — only relevant for the QBO source."
          },
          {
            "name": "vendor_ids",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated `vnd_<id>` filter."
          }
        ],
        "responses": {
          "200": {
            "description": "Missing-bills result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/MissingBillsResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/analytics/vendor-insights": {
      "get": {
        "operationId": "getVendorInsights",
        "summary": "Vendor billing insights",
        "description": "Returns vendor billing PATTERNS and bill history from the AP bill table — the canonical answer for vendor-schedule / cadence questions. Use this whenever the user asks about a \"vendor schedule\", \"vendor billing schedule\", \"billing patterns\", \"payment frequency / cadence\", or \"recurring vendors\". Pass `vendor_id`/`vendor_name` for a single vendor, or neither for an org-wide rollup that surfaces the recurring-billing-pattern vendors (cadence, average bill). The rollup returns the top 20 vendors ranked by spend (from the AP bill table), each with billing cadence and average bill — it is not a comprehensive spend ledger across every vendor. For a true \"top vendors by spend\" / \"how much did we spend with vendor X\" ranking, answer from the TRANSACTION ledger (`tools.transactions.list_transactions` — see the vendor-spend recipe), NOT from this tool and NOT from the GL bill side (`tools.gl.raw_glread`, `tools.vault.list_qbobills`): the AP bill table misses card/bank/wallet spend and one-off expense postings, so it structurally cannot reproduce a real spend ranking, and GL bill records are AP documents, not cash outflow. Do NOT use `tools.prepaid.list_prepaid_schedules` for vendor questions — that is prepaid-expense amortization, an unrelated concept that happens to share the word \"schedule\". For the raw vendor directory (names / ids only, no billing analysis) use `tools.vault.list_vault_vendors`. For the live GL-provider vendor list (QBO Vendor / Xero Supplier objects) use `tools.gl.list_glvendors`.",
        "tags": ["Analytics"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "vendor_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "`vnd_<id>` or bare id."
          },
          {
            "name": "vendor_name",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Vendor display name (e.g. `AWS`, `Datadog`), resolved server-side. Pass this OR `vendor_id`, not both; omit both for an org-wide rollup across all vendors."
          },
          {
            "name": "include_history",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, include the per-period bill-history rows (the last `months_of_history` months), not just the pattern summary. Default `false`."
          },
          {
            "name": "include_future",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, include projected upcoming bills inferred from the detected billing cadence. Default `false`."
          },
          {
            "name": "months_of_history",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 36
            },
            "description": "Months of bill history to analyze for the billing pattern (1-36). Default `3`."
          }
        ],
        "responses": {
          "200": {
            "description": "Vendor insights (single-vendor or org rollup).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/VendorInsightsResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/analytics/accrual-month-end": {
      "get": {
        "operationId": "getAccrualMonthEnd",
        "summary": "Month-end accrual report",
        "description": "Returns accrual accuracy metrics for a single `period` (YYYY-MM) OR an inclusive `start_period`..`end_period` range (max 24 months). Provide either `period` alone or both range bounds — they are mutually exclusive. `format=detailed` adds a per-vendor breakdown and recommendations; `format=executive` adds a single top recommendation.",
        "tags": ["Analytics"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-(0[1-9]|1[0-2])$"
            },
            "description": "Single month (YYYY-MM). Mutually exclusive with `start_period`/`end_period`."
          },
          {
            "name": "start_period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-(0[1-9]|1[0-2])$"
            },
            "description": "Inclusive range start (YYYY-MM). Requires `end_period`; mutually exclusive with `period`."
          },
          {
            "name": "end_period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-(0[1-9]|1[0-2])$"
            },
            "description": "Inclusive range end (YYYY-MM). Requires `start_period`; max 24 months from `start_period`."
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["summary", "detailed", "executive"]
            },
            "description": "Default `summary`."
          },
          {
            "name": "include_details",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Month-end accrual report.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AccrualMonthEndResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/prepaid": {
      "get": {
        "operationId": "listPrepaidSchedules",
        "summary": "List prepaid schedules",
        "description": "Lists persisted prepaid schedules AND virtual/detected schedules synthesized from unprocessed multi-month bills in real time. `schedule_id` is a UUID for persisted rows and a `virtual_<billId>` sentinel for detected ones. This is PREPAID-EXPENSE amortization (spreading an already-paid cost across its service period) — NOT a vendor billing schedule; for \"vendor schedule\" / vendor billing patterns use `tools.analytics.get_vendor_insights` instead.",
        "tags": ["Prepaid"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "vendor_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "`vnd_<id>` or bare id."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["active", "completed", "all"]
            },
            "description": "Default `active`."
          }
        ],
        "responses": {
          "200": {
            "description": "Prepaid schedules.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/PrepaidScheduleList"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/prepaid/process": {
      "post": {
        "operationId": "processPrepaid",
        "summary": "Process prepaid amortizations",
        "description": "Posts prepaid journal entries by detecting multi-month bills and creating their initial plus amortization entries. Idempotent (per-JE idempotency keys); the entries are system-originated.",
        "tags": ["Prepaid"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProcessPrepaidRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Processing summary + created JE ids.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ProcessPrepaidResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/qbo-bills": {
      "get": {
        "operationId": "listQBOBills",
        "summary": "List live QuickBooks bills (source of truth for a QBO org's bill history)",
        "description": "Lists bills live from the org’s connected QuickBooks company via a real-time Intuit query — the source of truth for bill history on a QBO org. Use this for \"show/list my bills\", \"do I have bills this year\", \"recent bills\", and other bill-RECORD requests (AP documents).\n\n**NOT a vendor-spend source.** It is NOT the source for \"how much did we spend with vendor X\" / \"top vendors by spend\" — bills are AP documents, not cash outflow, and miss card/bank/wallet spend; answer vendor-spend questions from `tools.transactions.list_transactions` (see the vendor-spend recipe) unless the user explicitly asks for figures direct from QuickBooks. The same transactions-first rule applies to `tools.gl.raw_glread` and `tools.analytics.get_vendor_insights` — none of the bill-side readers is the spend source.\n\n**QuickBooks only.** This reads the QuickBooks connection and nothing else. Xero, NetSuite, DualEntry, and Campfire expose journal entries rather than bill records — use `tools.gl.search_glrecords` there.\n\n**An empty or short result means \"unknown\", never \"zero\".** This endpoint answers `200` with an empty or partial list in several situations that are not \"the org has no bills\": the org has no QuickBooks connection at all (any non-QBO org lands here); `vendor_id` is a valid Entendre vendor that has no QuickBooks counterpart, or is not mapped to one yet; or the upstream QuickBooks query failed part-way through (auth expiry, rate limit, Intuit error) — the provider logs the failure and returns whatever it had already collected as a success, so a truncated list is indistinguishable from a complete one in the response. Before reporting an absence of bills, confirm the org is actually on QuickBooks (`tools.integrations.list_integrations`) and say the result is inconclusive rather than asserting zero. Do not use an empty result as evidence for an accounting decision.\n\n**Counts are not totals, and there is no cursor.** `total_bills` is the size of the returned page, `total_matching_bills` is how many matched before the limit was applied, and `max_results` (default 100, max 1000) trims the response after the fact — it does not bound the upstream fetch. There is no `has_more` or pagination token, so bills beyond 1000 matches cannot be enumerated through this endpoint; narrow `start_date`/`end_date` or `vendor_id` instead. Ordering is by bill date with no tie-breaker, so same-date bills are not guaranteed to keep a stable order between calls.\n\nFor a QBO-only org, prefer this over `tools.vault.list_vault_bills` — those uploaded/internal Vault documents can be stale or empty when QuickBooks is the book of record. But do NOT assume QuickBooks supersedes Vault on a HYBRID org that uses both: Vault may hold bills QuickBooks does not, so scope to the source the user means or check both before claiming a complete bill list. `vendor_id` is the internal vault vendor; `qbo_bill_id`/`external_vendor_id` are QuickBooks-side ids.",
        "tags": ["Vault"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "vendor_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Internal vault vendor (`vnd_<id>` or bare)."
          },
          {
            "name": "vendor_name",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
            }
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
            }
          },
          {
            "name": "max_results",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Live QBO bills.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/QBOBillList"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/qbo-bills/check": {
      "get": {
        "operationId": "checkQBOBillsForPeriod",
        "summary": "Which vendors billed in a period (QBO)",
        "description": "Returns per-vendor QuickBooks bill coverage for `period`, queried live from the org’s connected QuickBooks company. Used to gate accrual auto-posting (did this vendor already bill this month?). NOT a vendor-spend source — for \"how much did we spend with vendor X\" use `tools.transactions.list_transactions` (see the vendor-spend recipe).\n\n**QuickBooks only, and an empty result means \"unknown\".** An org without a QuickBooks connection gets empty coverage with a `200`, not an error. So does a period whose upstream query failed part-way — the provider logs the error and returns what it had collected as a success, so partial coverage is indistinguishable from complete coverage here. A vendor missing from the response therefore means \"no bill was observed\", NOT \"this vendor did not bill\": never post or suppress an accrual on the strength of an absence alone. Other GL providers expose journal entries rather than bill records; use `tools.gl.search_glrecords` there.",
        "tags": ["Vault"],
        "x-mcp-read-only": true,
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-(0[1-9]|1[0-2])$"
            },
            "description": "YYYY-MM."
          }
        ],
        "responses": {
          "200": {
            "description": "Per-vendor QBO coverage.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/QBOBillsCheckResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/vendor-schedule": {
      "post": {
        "operationId": "buildQBOVendorSchedule",
        "summary": "Build vendor schedule from QBO history",
        "description": "Generates vendor billing patterns from QuickBooks bill history, creating any missing vendors. Persists the vendor rows and their computed `vendor_patterns` (frequency, amounts, confidence).\n\nThis is a write — it creates vendors and overwrites their patterns. Call it only when the user explicitly asks to (re)build the vendor schedule from QBO; it's the setup step before `analytics.get_missing_bills` / `get_vendor_insights` can flag missing bills for a QBO org.",
        "tags": ["Vault"],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BuildVendorScheduleRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Schedule build summary + patterns.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BuildVendorScheduleResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/integrations": {
      "get": {
        "operationId": "listIntegrations",
        "summary": "List integrations",
        "description": "Lists authentication/connection status for this endpoint's fixed V1 provider catalog: accounting (QuickBooks, Xero, NetSuite, DualEntry, Campfire), cards (Ramp), receivables (Stripe), banking (Plaid), payroll (Finch), and Intuit vendor login. It does NOT return provider groups for Raincards, Fireblocks, Request Finance, Gmail, or Slack; their absence from this response is unknown, not a disconnected status. Metadata only — tokens and secrets are never returned.\n\nReturns one normalized group for every provider IN THAT FIXED CATALOG, so an absent connection for one of those providers appears as `connected: false, count: 0` rather than being omitted. The response shape is `{ data: Array<{ provider, category, connected, count, unavailable?, connections: Array<{ id, status, legal_entity_id, legal_entity_name, external_id, external_name, connected_at, last_activity_at }> }>, has_more: false, next_cursor: null, total_count }`.\n\n`connected` is a convenience boolean (true when at least one connection is `active`); the per-connection `status` enum is the source of truth. A provider whose status could not be determined (upstream/query error) is returned with `unavailable: true` — distinct from `connected: false`. The provider group's `count` is its total connection-record count; `data.length` and envelope `total_count` count provider groups, not connections. Derive the active connection count and active realm set from only `connections[]` rows whose `status` is `active`. For GL providers, each connection's `external_id` is its realm/tenant identifier. In particular, QuickBooks `realm_id` is `connections[].external_id`; there is no `connection.realm_id` field. It does NOT enumerate cached external company entities, configs, accounts, or mappings. One active connection does not prove there is no orphaned cached realm; compare active `connections[].external_id` values with config/entity `realm_id` values from `tools.gl.list_glconfigs` and `tools.gl.list_glexternal_entities`.\n\nThis endpoint is a fixed, bounded provider catalog and does not paginate: `has_more` is always false and `next_cursor` is always null. The accepted `limit` and `cursor` parameters are ignored compatibility fields.\n\nUse this for connection questions about providers in the fixed catalog, including Plaid, Finch, Stripe, Intuit, and general-ledger connections (filter `category=accounting`). Narrow the response with the `provider`, `category` (e.g. `accounting`, `banking`), `connected`, and `legal_entity_id` filters rather than fetching everything. The `legal_entity_id` filter applies ONLY to banking (Plaid), payroll (Finch), and receivables (Stripe) connections; GL, Ramp, and Intuit connections carry no legal entity and are filtered OUT (returning `count: 0`) under any `legal_entity_id`. So leave `legal_entity_id` unset unless you specifically want the Plaid/Finch/Stripe subset, and never read an empty group under an entity filter as \"provider not connected\" (it is a scoping artifact). Do NOT use it when the caller wants the individual corporate spend cards rather than the Ramp connection summary — call `tools.cards.list_cards` for that.",
        "x-mcp-no-entity-scope": true,
        "tags": ["Integrations"],
        "parameters": [
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated provider filter, e.g. `quickbooks,stripe`. Valid values: quickbooks, xero, netsuite, dualentry, campfire, ramp, stripe, plaid, finch, intuit. An unknown value returns 400."
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated category filter, e.g. `accounting,banking`. Valid values: accounting, cards, banking, payroll, receivables. Combined with `provider`, the two filters intersect. An unknown value returns 400."
          },
          {
            "name": "connected",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "When true, returns only providers with at least one active connection; when false, only those with none. Providers whose status is unavailable (unknown — an upstream error) are excluded from both."
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "A `le_*` id. Keeps only connections scoped to that legal entity. Providers whose connections carry no legal entity (GL, Ramp, Intuit) return with their connections filtered out (count: 0)."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Accepted for SDK call-shape uniformity with other v1 list endpoints, but ignored: the provider catalog is a fixed, bounded set with no pagination. The response always carries `has_more: false` and `next_cursor: null`."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Accepted for SDK call-shape uniformity with other v1 list endpoints, but ignored: the provider catalog is a fixed, bounded set with no pagination. The response always carries `has_more: false` and `next_cursor: null`."
          }
        ],
        "responses": {
          "200": {
            "description": "One normalized group per provider.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "provider": {
                            "type": "string"
                          },
                          "category": {
                            "type": "string",
                            "enum": ["accounting", "cards", "banking", "payroll", "receivables"]
                          },
                          "connected": {
                            "type": "boolean"
                          },
                          "count": {
                            "type": "integer"
                          },
                          "unavailable": {
                            "type": "boolean",
                            "description": "Present and true only when this provider's status could not be determined."
                          },
                          "connections": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "id": {
                                  "type": "string",
                                  "nullable": true
                                },
                                "status": {
                                  "type": "string",
                                  "enum": ["active", "expired", "error", "disconnected", "inactive", "pending"]
                                },
                                "legal_entity_id": {
                                  "type": "string",
                                  "nullable": true
                                },
                                "legal_entity_name": {
                                  "type": "string",
                                  "nullable": true,
                                  "description": "Human-readable name of legal_entity_id, resolved server-side. Null for org/realm-scoped providers (GL, Ramp, Intuit) or when unresolved."
                                },
                                "external_id": {
                                  "type": "string",
                                  "nullable": true
                                },
                                "external_name": {
                                  "type": "string",
                                  "nullable": true
                                },
                                "connected_at": {
                                  "type": "string",
                                  "format": "date-time",
                                  "nullable": true
                                },
                                "last_activity_at": {
                                  "type": "string",
                                  "format": "date-time",
                                  "nullable": true
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "Always false — the provider catalog is a fixed, bounded set with no pagination."
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true,
                      "description": "Always null for this endpoint; present so the envelope matches other v1 list responses."
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Number of provider groups returned (equal to the length of `data`)."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "operationId": "exchangeOAuthToken",
        "summary": "Exchange OAuth token",
        "description": "Exchanges an OAuth 2.0 authorization code for an access token plus refresh token, used by third-party applications after the user completes the authorization flow at `api.entendre.finance/oauth/authorize`. Pass the returned `access_token` as `Authorization: Bearer <token>` on subsequent v1 API calls. When the access token expires, call this endpoint again with `grant_type=refresh_token` and the saved refresh token to obtain a new access token without re-prompting the user.",
        "tags": ["Authentication"],
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["grant_type", "client_id", "client_secret"],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": ["authorization_code", "refresh_token"],
                    "description": "`authorization_code` for initial exchange, `refresh_token` to renew."
                  },
                  "client_id": {
                    "type": "string",
                    "description": "OAuth client ID from the Entendre developer dashboard."
                  },
                  "client_secret": {
                    "type": "string",
                    "description": "OAuth client secret."
                  },
                  "code": {
                    "type": "string",
                    "description": "Authorization code from the redirect (required when `grant_type=authorization_code`)."
                  },
                  "redirect_uri": {
                    "type": "string",
                    "description": "Must match the redirect URI used in the authorization request."
                  },
                  "refresh_token": {
                    "type": "string",
                    "description": "Refresh token (required when `grant_type=refresh_token`)."
                  }
                }
              },
              "examples": {
                "authorization_code": {
                  "summary": "Exchange authorization code",
                  "value": {
                    "grant_type": "authorization_code",
                    "client_id": "ent_client_abc123",
                    "client_secret": "ent_secret_xyz789",
                    "code": "AUTH_CODE_FROM_REDIRECT",
                    "redirect_uri": "https://yourapp.com/callback"
                  }
                },
                "refresh_token": {
                  "summary": "Refresh access token",
                  "value": {
                    "grant_type": "refresh_token",
                    "client_id": "ent_client_abc123",
                    "client_secret": "ent_secret_xyz789",
                    "refresh_token": "9f8e..."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token exchange successful.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthTokenResponse"
                },
                "example": {
                  "access_token": "ek_live_...",
                  "token_type": "Bearer",
                  "expires_in": 86400,
                  "refresh_token": "9f8e...",
                  "scope": "apis.read"
                }
              }
            }
          },
          "400": {
            "description": "Invalid grant, expired code, or mismatched redirect URI.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": {
                    "code": "INVALID_GRANT",
                    "message": "Authorization code has expired or has already been used.",
                    "display_message": "Authentication failed. Please try signing in again.",
                    "suggested_action": "Restart the OAuth flow to obtain a new authorization code.",
                    "documentation_url": "https://docs.entendre.finance/api-reference/authentication",
                    "request_id": "req_8nBpKqR2xLvMsT"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid client credentials.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": {
                    "code": "INVALID_CLIENT",
                    "message": "Client authentication failed.",
                    "display_message": "Invalid application credentials.",
                    "suggested_action": "Verify your client_id and client_secret from the developer dashboard.",
                    "documentation_url": "https://docs.entendre.finance/api-reference/authentication",
                    "request_id": "req_3mKpLqN7xBvWsY"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/organizations/me": {
      "get": {
        "operationId": "getCurrentOrganization",
        "summary": "Get current organization",
        "description": "Returns the organization metadata associated with the authenticated API key or OAuth token — name, prefixed `org_` ID, web address, timezone, time format, and onboarding status. Read-only. For member roster use `tools.organizations.list_organization_members`.",
        "tags": ["Organizations"],
        "responses": {
          "200": {
            "description": "Organization retrieved successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                },
                "example": {
                  "id": "org_123abc",
                  "name": "Acme Corp",
                  "created_at": "2023-01-15T08:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateCurrentOrganization",
        "summary": "Update current organization",
        "description": "Updates organization metadata for the org associated with the authenticated API key — `name`, `timezone`, `time_format`, `web_address`, and `logo_url`. Only the supplied fields are changed; omitted fields retain their existing values. At least one field is required. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Organizations"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "Organization display name."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "IANA timezone identifier."
                  },
                  "time_format": {
                    "type": "string",
                    "enum": ["hr12", "hr24"]
                  },
                  "web_address": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Organization website URL."
                  },
                  "logo_url": {
                    "type": ["string", "null"],
                    "maxLength": 2048,
                    "description": "Logo URL. Pass `null` or an empty string to clear the logo."
                  }
                }
              },
              "example": {
                "name": "Acme Corp",
                "timezone": "America/New_York",
                "time_format": "hr24"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Organization updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrganizationFull"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/legal-entities": {
      "get": {
        "operationId": "listLegalEntities",
        "summary": "List legal entities",
        "description": "Returns legal entities belonging to the authenticated organization. Results are returned in reverse chronological order (newest first). Cursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.\n\nOrdering caveat: in-app Copilot requests sort_by=name ascending and auto-pages at limit=100, so its 'list our legal entities' view is A to Z and complete on the first call. This HTTP endpoint defaults to sort_by=created_at, sort_direction=desc, limit=50, so a raw call yields a different order and a smaller first page. MCP passes ordering parameters through unchanged; pass sort_by=name, sort_direction=asc, limit=100 to match the Copilot view.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "include_archived",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "x-mcp-wrapper-default": true,
            "description": "When `true`, includes legal entities with `status: ARCHIVED` in the results. The HTTP API default is `false` (a UI \"what entities do I have?\" view hides retired subsidiaries). The MCP wrapper defaults this to `true` on every call to this tool so the agent can resolve an archived entity's name → id (the in-app copilot defaults it the same way); pass `false` for the active-only view. An explicit `?status=` filter takes precedence over this param."
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["name", "created_at", "updated_at"]
            },
            "description": "Field to sort by. Defaults to `created_at`. `created_at` and `name` are stable for ETL / full-list scans; `updated_at` is mutable — a row edited mid-pagination may be skipped, so reserve it for \"what changed recently\" queries."
          },
          {
            "name": "sort_direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["asc", "desc"]
            },
            "description": "Sort direction. Defaults to `desc` (newest first)."
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Case-insensitive substring match on the legal entity name."
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, includes a total `count` in the response. Opt-in — counting is skipped by default to keep list calls cheap."
          },
          {
            "name": "legal_entity_types",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter to one or more entity types (repeat the param, e.g. `?legal_entity_types=LLC&legal_entity_types=Corporation`). Values are validated against the supported entity-type set."
          },
          {
            "name": "currencies",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter to one or more base currencies (ISO 4217), repeated per value (e.g. `?currencies=USD&currencies=EUR`)."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "statuses",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string", "enum": ["active", "archived"] } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more legal-entity statuses (`active`, `archived`). Case-insensitive. Repeat the parameter per value (e.g. ?statuses=active&statuses=archived) or pass a comma-separated list. The singular alias `?status=` is also accepted. This is the canonical status filter."
          },
          {
            "name": "legal_entity_statuses",
            "in": "query",
            "required": false,
            "deprecated": true,
            "schema": { "type": "array", "items": { "type": "string", "enum": ["active", "archived"] } },
            "style": "form",
            "explode": true,
            "description": "Deprecated alias of `statuses`, retained for backward compatibility with existing callers. Same values and behavior; prefer `statuses`."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of legal entities.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LegalEntity"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "le_456def",
                      "name": "Acme Corp US LLC",
                      "entity_type": "LLC",
                      "status": "active",
                      "base_currency": "USD",
                      "address": {
                        "line1": "123 Main St",
                        "line2": null,
                        "city": "New York",
                        "state": "NY",
                        "country": "US",
                        "zipcode": "10001"
                      },
                      "created_at": "2023-01-15T08:00:00Z"
                    }
                  ],
                  "has_more": false,
                  "next_cursor": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createLegalEntity",
        "summary": "Create a legal entity",
        "description": "Creates a new legal entity (registered business) under the authenticated organization. Returns the new entity's prefixed `le_` ID; the entity is immediately eligible for transaction assignment, ledger-account scoping, and GL-entity mapping. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "entity_type", "base_currency"],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name for the legal entity."
                  },
                  "entity_type": {
                    "type": "string",
                    "enum": [
                      "C-CORP",
                      "S-CORP",
                      "LLC",
                      "LP",
                      "LLP",
                      "GmbH",
                      "Ltd",
                      "PLC",
                      "Foundation",
                      "Trust",
                      "Non-Profit",
                      "Sole Proprietorship",
                      "DAO",
                      "Other"
                    ],
                    "description": "Entity type."
                  },
                  "base_currency": {
                    "type": "string",
                    "description": "Base reporting currency (ISO 4217).",
                    "examples": ["USD", "EUR"]
                  },
                  "address": {
                    "$ref": "#/components/schemas/Address"
                  },
                  "address_string": {
                    "type": ["string", "null"],
                    "maxLength": 500,
                    "description": "Free-form, single-line address (max 500 chars). Stored independently of the structured `address` — the structured `address` is authoritative when both are set. `null` is treated as omitted."
                  }
                }
              },
              "example": {
                "name": "Acme Corp EU GmbH",
                "entity_type": "GmbH",
                "base_currency": "EUR",
                "address": {
                  "line1": "Unter den Linden 1",
                  "city": "Berlin",
                  "country": "DE",
                  "zipcode": "10117"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Legal entity created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LegalEntity"
                },
                "example": {
                  "id": "le_789ghi",
                  "name": "Acme Corp EU GmbH",
                  "entity_type": "GmbH",
                  "status": "active",
                  "base_currency": "EUR",
                  "address": {
                    "line1": "Unter den Linden 1",
                    "line2": null,
                    "city": "Berlin",
                    "state": null,
                    "country": "DE",
                    "zipcode": "10117"
                  },
                  "created_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/legal-entities/{id}": {
      "get": {
        "operationId": "getLegalEntity",
        "summary": "Get a legal entity",
        "description": "Returns a single legal entity by its prefixed `le_` ID — the response shape matches one row from `tools.legal_entities.list_legal_entities`. Use this for ID-based lookup; for browsing or filtering use the list endpoint. Read-only.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Legal entity found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LegalEntity"
                },
                "example": {
                  "id": "le_456def",
                  "name": "Acme Corp US LLC",
                  "entity_type": "LLC",
                  "status": "active",
                  "base_currency": "USD",
                  "address": {
                    "line1": "123 Main St",
                    "line2": null,
                    "city": "New York",
                    "state": "NY",
                    "country": "US",
                    "zipcode": "10001"
                  },
                  "created_at": "2023-01-15T08:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateLegalEntity",
        "summary": "Update a legal entity",
        "description": "Updates mutable fields on a legal entity — `name`, `status`, `entity_type`, `base_currency`, `address`, and `address_string`. Only the supplied fields are changed; omitted fields retain their existing values. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Updated display name."
                  },
                  "status": {
                    "type": "string",
                    "enum": ["active", "archived"],
                    "description": "Updated status."
                  },
                  "entity_type": {
                    "type": "string",
                    "enum": [
                      "C-CORP",
                      "S-CORP",
                      "LLC",
                      "LP",
                      "LLP",
                      "GmbH",
                      "Ltd",
                      "PLC",
                      "Foundation",
                      "Trust",
                      "Non-Profit",
                      "Sole Proprietorship",
                      "DAO",
                      "Other"
                    ],
                    "description": "Updated entity type. Omit to leave unchanged; `null` is not allowed."
                  },
                  "base_currency": {
                    "type": "string",
                    "description": "Updated base reporting currency (ISO 4217, e.g. `USD`). Changing it affects future pricing and derived data only; historical entries are not restated. Omit to leave unchanged; `null` is not allowed.",
                    "examples": ["USD", "EUR"]
                  },
                  "address": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/Address"
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "Replace the structured address. Set `null` to clear."
                  },
                  "address_string": {
                    "type": ["string", "null"],
                    "maxLength": 500,
                    "description": "Free-form, single-line address (max 500 chars). Set `null` to clear. Stored independently of the structured `address` — the structured `address` is authoritative when both are set."
                  }
                }
              },
              "example": {
                "name": "Acme Corp EU GmbH (Archived)",
                "status": "archived"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Legal entity updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LegalEntity"
                },
                "example": {
                  "id": "le_456def",
                  "name": "Acme Corp EU GmbH (Archived)",
                  "entity_type": "LLC",
                  "status": "archived",
                  "base_currency": "EUR",
                  "address": {
                    "line1": "Unter den Linden 1",
                    "line2": null,
                    "city": "Berlin",
                    "state": null,
                    "country": "DE",
                    "zipcode": "10117"
                  },
                  "created_at": "2023-01-15T08:00:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteLegalEntity",
        "summary": "Delete a legal entity",
        "description": "Deletes a single legal entity as a DESTRUCTIVE soft-delete (marked deleted and retained for audit). Blocks (returns reason) when the entity is referenced by transactions, journal entries, wallets, or ledger accounts — use `tools.legal_entities.bulk_archive_legal_entities` to deactivate without removing instead. Reference checks run in order (transactions, JEs, wallets, ledger accounts); the response identifies which dependency blocked deletion. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "204": {
            "description": "Legal entity deleted."
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Cannot delete — entity has references.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": {
                    "code": "CONFLICT",
                    "message": "Cannot delete legal entity — it has transactions. Archive it instead.",
                    "request_id": "req_5kDsQmN1vBpLwT"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/legal-entities/archive": {
      "post": {
        "operationId": "bulkArchiveLegalEntities",
        "summary": "Bulk archive legal entities",
        "description": "Archives up to 50 legal entities in one call as a DESTRUCTIVE soft-action — archived entities remain visible in audit views but are marked inactive and excluded from new transaction ingestion / report defaults. Entities already archived are returned in the `failed` array with a reason (partial-success semantics). To delete archived entities permanently use `tools.legal_entities.bulk_delete_legal_entities`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed legal entity IDs (`le_`)."
                  }
                }
              },
              "example": {
                "ids": ["le_111", "le_222"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["le_111"],
                    "failed": [
                      {
                        "id": "le_222",
                        "reason": "Legal entity is already archived"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/legal-entities/delete": {
      "post": {
        "operationId": "bulkDeleteLegalEntities",
        "summary": "Bulk delete legal entities",
        "description": "Deletes up to 50 legal entities in one call as a DESTRUCTIVE hard-delete. Each entity is independently checked for references (transactions, journal entries, wallets, ledger accounts) before deletion; entities with references are rejected individually while the rest of the batch proceeds (partial-success semantics). To deactivate without removing use `tools.legal_entities.bulk_archive_legal_entities` instead. Pass `idempotencyKey` to make the call replay-safe.\n\n### Safe operation workflow\n\n1. Call once with `dry_run: true` to preview the exact set — the response returns `{ dry_run, summary: { requested, eligible, failed }, eligible: [...], failed: [...] }` and changes nothing.\n2. Re-call without `dry_run`, setting `confirm_count` to the number of distinct ids you intend to affect; the request is rejected (400) if it does not equal the distinct id count.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed legal entity IDs (`le_`)."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the number of distinct ids in the request, otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns the eligible/failed preview without mutating anything. Use to preview the exact scope before committing."
                  }
                }
              },
              "example": {
                "ids": ["le_111", "le_222"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["le_111"],
                    "failed": [
                      {
                        "id": "le_222",
                        "reason": "Cannot delete legal entity — it has transactions. Archive it instead."
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/summary": {
      "get": {
        "operationId": "getFinancialAccountsSummary",
        "summary": "Get accounts summary",
        "description": "Returns a high-level summary of connected financial accounts as COUNTS ONLY, grouped by type: `wallet`, `exchange`, `bank_account`, plus a `total`. It returns NO balance figures and NO separate cards group. Useful for dashboard widgets that need a one-shot count snapshot. For per-account detail use `tools.financial_accounts.list_financial_accounts`; for balances use the balance endpoints.",
        "tags": ["Financial Accounts"],
        "responses": {
          "200": {
            "description": "Account summary.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["summary", "total"],
                  "properties": {
                    "summary": {
                      "type": "object",
                      "required": ["wallet", "exchange", "bank_account"],
                      "properties": {
                        "wallet": {
                          "type": "integer"
                        },
                        "exchange": {
                          "type": "integer"
                        },
                        "bank_account": {
                          "type": "integer"
                        }
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "summary": {
                    "wallet": 12,
                    "exchange": 4,
                    "bank_account": 5
                  },
                  "total": 21
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts": {
      "get": {
        "operationId": "listFinancialAccounts",
        "summary": "List financial accounts",
        "x-mcp-no-entity-scope": true,
        "description": "Returns wallets, bank accounts, and exchange connections — the inventory of where money sits. Results in reverse chronological order (newest first); for full enumeration set `limit: 100`.\n\n**This endpoint does NOT return live balances.** Each row carries identity + metadata only — provider, type, addresses, account-last-4, sync state.\n\n### Pair with a balance endpoint\n\n- `GET /v1/balances/token` — on-chain wallets, keyed by `financial_account_id`.\n- `GET /v1/balances/exchange` — CCXT exchange balances.\n- `GET /v1/balances` — GL-derived view of period balances.\n\n### Resolving by name\n\nTo resolve an account or source by NAME, search BOTH this endpoint and `GET /sources` — the inventory is split across the two. Then: exactly 1 match → use its id; 2+ matches → ask the user which one; 0 matches → list what exists rather than claim it is not connected.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["wallet", "exchange", "bank_account"]
            },
            "description": "Filter by account type."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["healthy", "disconnected", "error"]
            },
            "description": "Filter by status."
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Case-insensitive substring match on the account name (regex-escaped — not a raw regex)."
          },
          {
            "name": "chains",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter wallets by chain (comma-separated). Wallet-only; ignored for exchanges/bank accounts."
          },
          {
            "name": "wallet_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": ["internal", "external"]
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter wallets by type (comma-separated). Wallet-only."
          },
          {
            "name": "addresses",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter wallets by on-chain address (comma-separated, case-insensitive). Wallet-only."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by legal entity (comma-separated prefixed `le_` ids). Applies across all account types."
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Fetch a specific set of accounts by prefixed `fac_` id (comma-separated), across all types."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of financial accounts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FinancialAccount"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "fac_0987654321",
                      "type": "wallet",
                      "provider": "fireblocks",
                      "name": "Treasury Cold Storage",
                      "legal_entity_id": "le_456def",
                      "status": "healthy",
                      "last_synced_at": "2024-03-10T14:30:00Z",
                      "details": {
                        "chain": "eth",
                        "address": "0x71C..."
                      },
                      "created_at": "2023-06-15T10:00:00Z"
                    }
                  ],
                  "has_more": false,
                  "next_cursor": "dHhuXzg5MF9hYmM="
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/wallets": {
      "post": {
        "operationId": "createWallet",
        "summary": "Create a wallet",
        "description": "Creates an on-chain wallet connection by address and chain — read-only, no private-key custody. The wallet is then included in subsequent on-chain ingestion runs for the configured chain. Returns the persisted wallet record with its prefixed `fac_` ID. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "chain", "address"],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name."
                  },
                  "wallet_type": {
                    "type": "string",
                    "enum": ["internal", "external"],
                    "default": "external",
                    "description": "Wallet type. Defaults to `external`. `internal` wallets require `legal_entity_id`."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed legal entity ID. Required for internal wallets; optional for external.",
                    "examples": ["le_456def"]
                  },
                  "chain": {
                    "type": "string",
                    "description": "Blockchain identifier.",
                    "examples": ["eth", "sol", "btc"]
                  },
                  "address": {
                    "type": "string",
                    "description": "On-chain wallet address."
                  },
                  "tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed `tag_` ids to apply to the wallet."
                  },
                  "allow_nft_import": {
                    "type": "boolean",
                    "description": "Whether to import NFTs for this wallet on supported chains."
                  }
                }
              },
              "example": {
                "name": "Operating Wallet",
                "wallet_type": "external",
                "legal_entity_id": "le_456def",
                "chain": "sol",
                "address": "7a3B..."
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Wallet created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinancialAccount"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/exchanges": {
      "post": {
        "operationId": "createExchange",
        "summary": "Create an exchange connection",
        "description": "Creates an exchange-account connection using read-only API keys for a supported exchange (Binance, Coinbase, Kraken, BitMEX, Deribit). The credentials are encrypted at rest with the org-scoped pepper and used only for subsequent ingestion runs. Returns the persisted exchange record with its prefixed `fac_` ID. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "legal_entity_id", "provider", "api_key", "api_secret"],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed legal entity ID."
                  },
                  "provider": {
                    "type": "string",
                    "description": "Exchange provider (case-insensitive).",
                    "enum": [
                      "kraken",
                      "binance",
                      "coinbase",
                      "coinbase_prime",
                      "coinbase_exchange",
                      "coinbase_international",
                      "deribit",
                      "bitmex",
                      "bybit",
                      "kucoin",
                      "gate",
                      "mexc",
                      "gemini",
                      "woo",
                      "okx",
                      "bitfinex",
                      "circle",
                      "canton"
                    ]
                  },
                  "api_key": {
                    "type": "string",
                    "description": "Exchange API key. Encrypted at rest."
                  },
                  "api_secret": {
                    "type": "string",
                    "description": "Exchange API secret. Encrypted at rest."
                  },
                  "api_passphrase": {
                    "type": "string",
                    "description": "Exchange API passphrase (required by some exchanges)."
                  }
                }
              },
              "example": {
                "name": "Kraken Trading",
                "legal_entity_id": "le_456def",
                "provider": "kraken",
                "api_key": "key_here",
                "api_secret": "secret_here"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Exchange connection created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinancialAccount"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "The exchange could not be reached to verify the API keys within the deadline (`PROVIDER_ERROR`). The credentials were NOT saved; retry."
          }
        }
      }
    },
    "/financial-accounts/bank-accounts": {
      "post": {
        "operationId": "createBankAccount",
        "summary": "Create a bank account (Plaid)",
        "description": "Creates one or more bank accounts by exchanging a Plaid `public_token` obtained from the Plaid Link UI widget. Call `POST /financial-accounts/plaid/link-token` first to mint the token used to bootstrap the Link flow. Returns the persisted bank-account records with their prefixed `fac_` IDs. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["public_token"],
                "properties": {
                  "public_token": {
                    "type": "string",
                    "description": "Plaid public token from Link UI."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed legal entity ID."
                  }
                }
              },
              "example": {
                "public_token": "public-sandbox-token-from-plaid-link",
                "legal_entity_id": "le_456def"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Bank account(s) created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinancialAccount"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/plaid/link-token": {
      "post": {
        "operationId": "generatePlaidLinkToken",
        "summary": "Generate Plaid link token",
        "description": "Generates the `link_token` required to initialize the Plaid UI widget. Pass the returned token to Plaid Link; once the user authenticates, Plaid returns a `public_token` to use with `POST /financial-accounts/bank-accounts`.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["legal_entity_id"],
                "properties": {
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed legal entity ID."
                  }
                }
              },
              "example": {
                "legal_entity_id": "le_456def"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Link token generated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "link_token": {
                      "type": "string",
                      "description": "Pass this to the Plaid Link UI widget."
                    }
                  }
                },
                "example": {
                  "link_token": "link-sandbox-abc123def456..."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/{id}": {
      "get": {
        "operationId": "getFinancialAccount",
        "summary": "Get a financial account",
        "description": "Retrieves a single financial account by its prefixed ID. Searches across wallets, exchanges, and bank accounts. Returns the same shape as a list response item.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Financial account found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinancialAccount"
                },
                "example": {
                  "id": "fac_0987654321",
                  "type": "wallet",
                  "provider": "fireblocks",
                  "name": "Treasury Cold Storage",
                  "legal_entity_id": "le_456def",
                  "status": "healthy",
                  "last_synced_at": "2024-03-10T14:30:00Z",
                  "details": {
                    "chain": "eth",
                    "address": "0x71C..."
                  },
                  "created_at": "2023-06-15T10:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateFinancialAccount",
        "summary": "Update a financial account",
        "description": "Updates mutable fields on a financial-account connection — `name`, legal-entity mapping (`legal_entity_id`), tags, ledger-account mapping (`ledger_account_id`), and (wallets only) `allow_nft_import`. Tags have two modes: `tag_ids` REPLACES the whole tag set (empty array clears everything), while `add_tag_ids` / `remove_tag_ids` MERGE against the current set — use the merge fields for \"add/remove a tag\" so you don't wipe the account's other tags. The two modes are mutually exclusive in one request. Credentials and address are immutable after creation; delete and recreate via `tools.financial_accounts.delete_financial_account` if those must change.\n\n**Wallet side effects:** assigning a legal entity to a wallet still in `PENDING_SETUP` promotes it to `ACTIVE` so scheduled sync/import starts picking it up. An **internal** wallet must always have a legal entity. Every update that would leave it without one — clearing it (`legal_entity_id: null`), or patching an internal wallet that already has none — is rejected with `400`. Rescue such a wallet by assigning a legal entity in the same request. External wallets may clear it.\n\nPass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "legal_entity_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `le_` id, or null to clear."
                  },
                  "tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "REPLACE the account's entire tag set with these prefixed `tag_` ids (empty array clears ALL tags). To add or remove a single tag WITHOUT touching the others, use `add_tag_ids` / `remove_tag_ids` instead — `tag_ids` overwrites everything. Mutually exclusive with `add_tag_ids`/`remove_tag_ids` in one request."
                  },
                  "add_tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "MERGE: add these prefixed `tag_` ids to the account's current tags without removing the others. Adding a tag whose tag-group (key) already exists on the account replaces that existing same-group tag. Unknown or cross-org ids are silently ignored (never added). Mutually exclusive with `tag_ids`."
                  },
                  "remove_tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "MERGE: remove these prefixed `tag_` ids from the account's current tags, leaving the rest in place. Ids not on the account — or not owned by this org — are ignored. Mutually exclusive with `tag_ids`."
                  },
                  "ledger_account_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `lac_` id to map, or null to clear."
                  },
                  "allow_nft_import": {
                    "type": "boolean",
                    "description": "Wallet-only; whether to import NFTs on supported chains."
                  }
                }
              },
              "example": {
                "name": "Archived Legacy Wallet",
                "legal_entity_id": "le_999new"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Financial account updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinancialAccount"
                },
                "example": {
                  "id": "fac_0987654321",
                  "type": "wallet",
                  "provider": "fireblocks",
                  "name": "Archived Legacy Wallet",
                  "legal_entity_id": "le_999new",
                  "status": "healthy",
                  "last_synced_at": "2024-03-10T14:30:00Z",
                  "details": {
                    "chain": "eth",
                    "address": "0x71C..."
                  },
                  "created_at": "2023-06-15T10:00:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteFinancialAccount",
        "summary": "Delete a financial account",
        "description": "Deletes a financial-account connection (bank, exchange, or wallet) as a DESTRUCTIVE removal of the credential / address link. Historical transactions already ingested are preserved but the account stops receiving new sync runs immediately. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "204": {
            "description": "Financial account deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/{id}/sync": {
      "post": {
        "operationId": "triggerSync",
        "summary": "Trigger sync",
        "description": "Triggers an asynchronous data-synchronization run for one financial account. Returns 202 with a job id, but that id is a queue id — it is NOT pollable via `tools.jobs.get_job_status` (that tool expects a `job_`-prefixed JobConfiguration id and will 404 on this one). To detect completion, re-read the account via `tools.financial_accounts.list_financial_accounts` and watch its `last_synced_at` / `status`, or subscribe to the `sync.completed` / `sync.failed` webhooks.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "202": {
            "description": "Sync job enqueued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string",
                      "examples": ["job_987xyz"]
                    }
                  }
                },
                "example": {
                  "job_id": "job_987xyz"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/financial-accounts/{id}/import": {
      "post": {
        "operationId": "importWalletTransactions",
        "summary": "Import wallet transactions",
        "description": "Enqueues a dated transaction backfill for an internal wallet (job name `IMPORT_TRANSACTION_JOB`). Unlike `POST /financial-accounts/{id}/sync` (real-time refresh from the last cursor), this re-pulls history from `import_start_date`. The wallet must be internal and have a legal entity, and the start date must not fall inside a closed accounting period (400 otherwise). `reference_id` in the response is the raw wallet id the progress stream keys on.",
        "tags": ["Financial Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["import_start_date"],
                "properties": {
                  "import_start_date": {
                    "type": "string",
                    "description": "ISO date/timestamp to backfill transactions from.",
                    "examples": ["2026-01-01"]
                  },
                  "skip_classification": {
                    "type": "boolean",
                    "default": false,
                    "description": "Skip automatic transaction classification on import."
                  }
                }
              },
              "example": {
                "import_start_date": "2026-01-01",
                "skip_classification": false
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Import job enqueued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_name": {
                          "type": "string",
                          "examples": ["IMPORT_TRANSACTION_JOB"]
                        },
                        "job_id": {
                          "type": ["string", "null"]
                        },
                        "reference_id": {
                          "type": "string",
                          "description": "Raw wallet id the progress stream keys on."
                        },
                        "account_id": {
                          "type": ["string", "null"]
                        },
                        "status": {
                          "type": "string",
                          "examples": ["queued"]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/financial-accounts/bulk-delete": {
      "post": {
        "operationId": "bulkDeleteFinancialAccounts",
        "summary": "Bulk-delete wallets",
        "description": "Soft-deletes a batch of wallets by prefixed `fac_` id. All-or-nothing: the whole request is rejected (400) if any id belongs to another org or if any wallet has transactions with journal entries (accounting). Max 500 ids.",
        "tags": ["Financial Accounts"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 500,
                    "description": "Prefixed `fac_` wallet ids to delete."
                  }
                }
              },
              "example": {
                "ids": ["fac_0987654321", "fac_1234567890"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Wallets soft-deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "deleted_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "success": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/sources/bulk-patch": {
      "post": {
        "operationId": "bulkPatchSources",
        "summary": "Bulk-patch sources",
        "description": "Applies one uniform update across a heterogeneous batch of sources (wallets, exchanges, Plaid accounts, manual banks, Raincards, Ramp cards, Ramp bank accounts, Niural). Each source carries its `type`; ids may be prefixed or bare. Field/type contract: `legal_entity_id` applies to all eight types (clearing it is rejected for an internal wallet); `ledger_account_id` applies to every type except Niural, where it is silently skipped; `tag_ids` applies only to wallet, exchange, Plaid, and manual-bank sources, and is silently skipped for Raincards, Ramp cards, Ramp bank accounts, and Niural. A source whose only requested fields are unsupported still appears in `succeeded[]` as a no-op: `succeeded` means the batch accepted that row, not that every requested field was written. Per-row failures (cross-org id, rejected legal-entity clear, malformed id) come back in `failed[]` rather than failing the whole request. Max 500 sources.",
        "tags": ["Sources"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["sources", "updates"],
                "properties": {
                  "sources": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": {
                      "type": "object",
                      "required": ["id", "type"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "description": "Source id (prefixed or bare)."
                        },
                        "type": {
                          "type": "string",
                          "enum": [
                            "wallet",
                            "exchange",
                            "plaid",
                            "manualBank",
                            "raincard",
                            "rampCard",
                            "rampBankAccount",
                            "niural"
                          ]
                        }
                      }
                    }
                  },
                  "updates": {
                    "type": "object",
                    "description": "Provide at least one field. Omit a field to leave it unchanged.",
                    "properties": {
                      "legal_entity_id": {
                        "type": ["string", "null"],
                        "description": "Prefixed `le_` id, or null to clear."
                      },
                      "ledger_account_id": {
                        "type": ["string", "null"],
                        "description": "Prefixed `lac_` id, or null to clear. Applied to every source type except Niural; for Niural it is silently skipped and the row can still appear in `succeeded[]` as a no-op."
                      },
                      "tag_ids": {
                        "type": ["array", "null"],
                        "items": { "type": "string" },
                        "description": "REPLACE the complete tag set with these prefixed `tag_` ids (empty array clears; null/omitted leaves unchanged). Supported only by wallet, exchange, Plaid, and manual-bank sources. For Raincards, Ramp cards, Ramp bank accounts, and Niural it is silently skipped; a row with only this unsupported field can still appear in `succeeded[]` as a no-op."
                      }
                    }
                  }
                }
              },
              "example": {
                "sources": [
                  {
                    "id": "fac_0987654321",
                    "type": "wallet"
                  },
                  {
                    "id": "src_1234567890",
                    "type": "niural"
                  }
                ],
                "updates": {
                  "legal_entity_id": "le_456def"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-source results.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["message", "succeeded", "failed"],
                      "properties": {
                        "message": {
                          "type": "string"
                        },
                        "succeeded": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string"
                              },
                              "type": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "failed": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string"
                              },
                              "type": {
                                "type": "string"
                              },
                              "reason": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards": {
      "get": {
        "operationId": "listCards",
        "summary": "List cards",
        "description": "Returns Ramp corporate spend card connections. Results are returned in reverse chronological order (newest first). Rain cards are represented by the Sources API, not this endpoint.\n\nPagination is page-based: pass `page` (1-based) and read `next_page` from the response to fetch the next page (`null` on the last page). `page_size` (alias `limit`, max 100) controls page size. Use the same page size on every page of one traversal; changing it can skip or overlap offset pages. There is no cursor on this endpoint.\n\nUse this to list the actual cards. To answer whether the Ramp connection is connected — a `connected` boolean + count — use `tools.integrations.list_integrations`.",
        "tags": ["Cards"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by legal entity."
          },
          {
            "name": "state",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["active", "suspended", "terminated", "pending", "unknown"]
            },
            "description": "Filter cards by lifecycle state. Case-insensitive."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-based page number. Use the response's `next_page` to advance through all cards."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Cards per page (1-100, default 25). Alias of `limit`."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of cards.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Card"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_page": {
                      "type": ["integer", "null"],
                      "description": "Next page number to request via the `page` query parameter. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "crd_0987654321",
                      "provider": "ramp",
                      "name": "Ramp Corporate Cards",
                      "legal_entity_id": "le_456def",
                      "status": "healthy",
                      "last_synced_at": "2024-03-10T14:30:00Z",
                      "created_at": "2023-09-01T12:00:00Z"
                    }
                  ],
                  "has_more": false,
                  "next_page": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/{id}": {
      "get": {
        "operationId": "getCard",
        "summary": "Get a card",
        "description": "Returns a single Ramp card by its prefixed id (`crd_…`). The response includes the cardholder, last four digits, card state, and the linked legal entity.",
        "tags": ["Cards"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The card.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Card"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/connect/oauth": {
      "post": {
        "operationId": "startCardOAuth",
        "summary": "Start the Ramp OAuth flow",
        "description": "Connects the organization to Ramp by starting the OAuth flow and returning the authorize URL. Open the returned URL in a browser to grant consent; Ramp redirects back to the configured callback, which finalizes the connection.\n\n`return_url` must be an https URL whose hostname is in the OAuth return-URL allowlist (entendre domains; localhost over http for dev).",
        "tags": ["Cards"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["return_url"],
                "additionalProperties": false,
                "properties": {
                  "return_url": {
                    "type": "string",
                    "description": "Where to send the user after the OAuth flow completes. Must be an allowlisted https URL."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The Ramp authorize URL.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["authorize_url"],
                      "properties": {
                        "authorize_url": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/entities": {
      "get": {
        "operationId": "listCardEntities",
        "summary": "List Ramp entities",
        "description": "Lists the raw Ramp entities (businesses / subsidiaries) available for entity-mapping configuration — the left-hand side of the mapping UI.\n\nDistinct from `GET /cards/entity-mappings`, which returns the Ramp-entity → Entendre-legal-entity mappings already saved.",
        "tags": ["Cards"],
        "responses": {
          "200": {
            "description": "The org's Ramp entities.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": ["string", "null"]
                          },
                          "entity_name": {
                            "type": ["string", "null"]
                          },
                          "is_primary": {
                            "type": "boolean"
                          },
                          "currency": {
                            "type": ["string", "null"]
                          }
                        }
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/entity-mappings": {
      "get": {
        "operationId": "getCardEntityMappings",
        "summary": "List entity mappings",
        "description": "Returns the saved Ramp-entity → Entendre-legal-entity mappings. `legal_entity_id` is a prefixed id (`le_…`), `null` where unmapped.",
        "tags": ["Cards"],
        "responses": {
          "200": {
            "description": "The org's entity mappings.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "ramp_entity_id": {
                            "type": "string"
                          },
                          "legal_entity_id": {
                            "type": ["string", "null"]
                          }
                        }
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "setCardEntityMappings",
        "summary": "Replace entity mappings",
        "description": "Replaces ALL Ramp-entity → legal-entity mappings for the org (delete-all-then-insert). Each `legal_entity_id` must be a prefixed id (`le_…`) belonging to the org.",
        "tags": ["Cards"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["mappings"],
                "additionalProperties": false,
                "properties": {
                  "mappings": {
                    "type": "array",
                    "minItems": 0,
                    "maxItems": 200,
                    "description": "Full replacement set. An empty array clears all mappings.",
                    "items": {
                      "type": "object",
                      "required": ["ramp_entity_id", "legal_entity_id"],
                      "additionalProperties": false,
                      "properties": {
                        "ramp_entity_id": {
                          "type": "string"
                        },
                        "legal_entity_id": {
                          "type": "string",
                          "description": "Prefixed legal entity id (`le_…`)."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mappings replaced.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["count", "message"],
                      "properties": {
                        "count": {
                          "type": "integer"
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/sync-jobs": {
      "post": {
        "operationId": "createCardSyncJob",
        "summary": "Trigger a Ramp sync",
        "description": "Triggers a Ramp data sync (transactions, bills, reimbursements, statements) and returns the sync job id (`rms_…`). Poll `GET /cards/sync-jobs/{id}` for status.\n\nThe sync window defaults to the last 30 days when `start_date` is omitted. Returns `409` if a sync is already in progress for the org.",
        "tags": ["Cards"],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "start_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "ISO timestamp. Defaults to 30 days ago when omitted."
                  },
                  "end_date": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "force_full_sync": {
                    "type": "boolean",
                    "description": "Bypass the incremental cursor and re-pull the whole window. Use sparingly."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Sync queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["sync_log_id", "sync_status", "message"],
                      "properties": {
                        "sync_log_id": {
                          "type": ["string", "null"],
                          "description": "Prefixed sync job id (`rms_…`)."
                        },
                        "sync_status": {
                          "type": "string"
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "get": {
        "operationId": "listCardSyncJobs",
        "summary": "List sync jobs",
        "description": "Returns the Ramp sync history for the org (most recent first), page-based. Filter by `status` to narrow to a single sync state such as completed or failed.",
        "tags": ["Cards"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["pending", "in_progress", "completed", "failed", "cancelled"]
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            },
            "description": "Rows per page (max 200; default 50). This endpoint is page-based, not cursor-based: use `page` + `page_size`, not `limit`/`cursor`. The generated MCP tool auto-forwards the universal `limit` → `page_size` for convenience; passing both at once returns a 400 with a migration hint.",
            "x-deprecated-aliases": ["limit"]
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated sync jobs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CardSyncJob"
                      }
                    },
                    "count": {
                      "type": "integer",
                      "description": "Number of rows returned in THIS page (equals `data.length`, at most `page_size`), NOT the total across all pages. To page through everything, follow `next_page` while `has_more` is true; there is no total-count field (an unbounded count query is intentionally avoided)."
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_page": {
                      "type": ["integer", "null"]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/sync-jobs/{id}": {
      "get": {
        "operationId": "getCardSyncJob",
        "summary": "Get a sync job",
        "description": "Returns a single Ramp sync job by its prefixed id (`rms_…`). The response includes the run status and per-type counts (transactions, bills, statements, and reimbursements added/modified).",
        "tags": ["Cards"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "The sync job.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/CardSyncJob"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/webhooks": {
      "get": {
        "operationId": "listCardWebhooks",
        "summary": "List inbound Ramp webhooks",
        "description": "Returns the inbound Ramp webhook configurations the org has registered (the webhooks Ramp sends TO Entendre). Read-only — Ramp manages their lifecycle.\n\nNot to be confused with the outbound `/webhooks` publisher subscriptions.",
        "tags": ["Cards"],
        "responses": {
          "200": {
            "description": "The configured inbound Ramp webhooks.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CardWebhook"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createCardWebhook",
        "summary": "Register the inbound Ramp webhook",
        "description": "Creates THE Entendre inbound Ramp webhook for the org, subscribing to the event types Entendre processes. The endpoint URL and event-type subscription are built server-side — no request body is required.\n\nThe signing secret is never returned.",
        "tags": ["Cards"],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {}
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The registered webhook.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/CardWebhook"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/webhooks/{id}": {
      "delete": {
        "operationId": "deleteCardWebhook",
        "summary": "Delete an inbound Ramp webhook",
        "description": "Deletes an inbound Ramp webhook by its raw Ramp id (the `ramp_webhook_id` surfaced by `GET /cards/webhooks`). This id is NOT an Entendre prefixed id.",
        "tags": ["Cards"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The raw Ramp webhook id."
          }
        ],
        "responses": {
          "200": {
            "description": "Webhook deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["success"],
                      "properties": {
                        "success": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/transactions": {
      "get": {
        "operationId": "listTransactions",
        "summary": "List transactions",
        "description": "Returns financial events from connected accounts and cards in reverse chronological order.\n\nFor \"how much did we spend on X\" / \"find a transaction for $Y\" patterns, this is the primary endpoint \u2014 do not start with `GET /v1/journal-entries` for vendor or amount lookups. For per-vendor / per-merchant / per-cardholder SPEND rankings this transaction ledger is the canonical source, even on an org with a connected healthy GL \u2014 NOT `tools.gl.raw_glread`, `tools.vault.list_qbobills`, or `tools.analytics.get_vendor_insights` (bill-side AP records and analytics, not cash outflow).\n\n### Terminology \u2014 do not conflate these\n\n- **Transaction type** \u2014 `internal.transaction_type` values such as `WITHDRAWAL`, `DEPOSIT`, and `SWAP`. They describe the economic event; they are not an accounting classification. `internal.category` is the legacy alias.\n- **Accounting treatment** \u2014 the Chart-of-Accounts accounts and debit/credit lines on the linked journal entry. This is the accounting classification; never infer it from transaction type or provider metadata.\n- **Provider category** \u2014 `provider_category` is raw Ramp/Plaid metadata. `source_category` is its legacy alias. Neither is accounting treatment.\n- **Journal-entry coverage** \u2014 `has_posted_journal_entry=false` means no posted journal entry exists. It says nothing about transaction type. `has_journal_entry` and `has_accounting` are legacy aliases. It filters POSTED coverage only, so a transaction returned here may still own a `DRAFT` or `ERROR` entry \u2014 read the entry itself before describing one of these transactions as posted, and never report a transaction from this filter as having booked successfully.\n\n### Common filters\n\n- `?search=` \u2014 vendor / merchant / memo lookup. Example: `?search=Amazon%20Web%20Services`. Merchant strings may not match common abbreviations; try the full name if `AWS` returns zero.\n- `?has_posted_journal_entry=false` \u2014 find transactions without a posted journal entry.\n- `?limit=100` \u2014 max enforced by the schema. Default `50` is sized for interactive lookups.\n\n### Field conventions\n\n- `external.quantity` \u2014 gross token quantity before fees (crypto only, e.g. `\"1.5\"` ETH). **Never a dollar value.**\n- `external.fee` \u2014 token fee (e.g. gas; crypto only, same unit as `currency`).\n- `external.value_fiat.gross` \u2014 `quantity \u00d7 unit_price`. Total fiat value before fees. **This is the amount used for journal-entry lines.**\n- `external.value_fiat.fee` \u2014 fee in fiat terms.\n- `external.value_fiat.net` \u2014 fiat value after fees.\n- `external.direction` \u2014 `credit` (inbound) or `debit` (outbound).\n- `source_class` \u2014 `crypto`, `fiat`, `card`, `exchange`, or `other`.\n\n### How to report one transaction\n\nFor accounting treatment or JE/GL state, get the transaction, then its linked journal entry, then the ledger accounts referenced by its lines. The transaction's `internal.status` is not the journal-entry lifecycle source of truth.\n\n### Source-class blocks\n\nResponse includes one of:\n\n- `crypto` \u2014 `chain`, addresses, `hash`.\n- `bank` \u2014 `bank_name`, `account_last4`, `counterparty_name`.\n- `card` \u2014 `merchant_name`, `merchant_category`, `card_last4`, `employee_name`.\n\n### Source filtering: two distinct cases\n\nA. User names a source TYPE ('ramp', 'card', 'bill pay', 'bank', 'wallet', 'exchange'): map the phrase to source_types directly, no source lookup needed. ramp/card maps to RAMP_CARD; bill pay maps to RAMP_BANK_ACCOUNT; bank/plaid maps to BANK_SOURCE; wallet/crypto maps to WALLET; exchange maps to EXCHANGE_SOURCE.\n\nB. User names a SPECIFIC source by name, institution, or last-4 ('BofA', 'Mercury', 'Amex Platinum', '...4521'): resolve it FIRST by name via the sources inventory (GET /v1/sources and GET /v1/financial-accounts), then pass the resolved id to financial_account_ids. On 2 or more matches, ask the user which one; on 0 matches, list what sources DO exist rather than claiming the source is not connected.\n\nNEVER substring-match the user's phrase against the source_types enum: that is the bug that produced false 'no Ramp connected' answers. Never reject a question just because the phrase does not appear in the enum.\n\n### Month name without a year\n\nFor a month-name request with no explicit year (e.g. 'March transactions'), use the CURRENT calendar year unless the user names another year.\n\n### Targeted lookups drop the coverage filter\n\nA targeted lookup (search, transaction_sequence_numbers, or transaction_ids) must NOT be combined with has_posted_journal_entry (or its aliases has_journal_entry / has_accounting). Together they silently narrow to only-transactions-without-a-posted-JE, so a targeted match that already has a posted entry returns EMPTY. Omit the coverage filter when doing a targeted lookup.\n\n### Spam rows\n\nSpam is EXCLUDED by default. status=spam returns spam-ONLY (it flips the result set to spam, it does not widen it). To widen the result set instead, pass include_spam=true, which returns spam rows ALONGSIDE non-spam rows; the in-app copilot passes it on every query. Use spam_only=true (equivalent to status=spam) for the spam-only view, and do not combine the two.\n\n### provider_category matching\n\nprovider_category (legacy alias source_category) is a case-insensitive SUBSTRING match against raw Ramp accounting categories and Plaid PFC labels: pass the user's own phrasing and let it normalize. Strip trailing plurals ('restaurants' to 'restaurant'). Use Ramp's ':' separator for hierarchical categories (e.g. 'Travel:Hotels'). It is never an accounting-treatment filter.",
        "tags": ["Transactions"],
        "x-mint": {
          "content": "## Transaction Type Reference\n\nThese values describe the economic type of a transaction event. They are **not** accounting classifications; accounting treatment is the selected Chart-of-Accounts account on the linked journal entry.\n\n<Warning>\nSome values use spaces instead of underscores (e.g., `INTERCOMPANY TRANSFER`, `CLAIM REWARD`). Always reference the exact values below.\n</Warning>\n\n| Transaction type | Description |\n|----------|-------------|\n| `DEPOSIT` | Inbound receipt of funds |\n| `WITHDRAWAL` | Outbound transfer of funds |\n| `SWAP` | Exchange of one asset for another |\n| `BRIDGE` | Cross-chain transfer |\n| `INTERCOMPANY TRANSFER` | Transfer between legal entities |\n| `INTERNAL TRANSFER` | Transfer within the same legal entity |\n| `FEE` | Network gas, exchange, or service fee |\n| `MINTING` | Creation of new tokens |\n| `STAKING_REWARD` | Yield from staking |\n| `INVOICE` | Payment received against a customer invoice |\n| `BILL` | Payment against a vendor bill |\n| `CLAIM REWARD` | Protocol or ecosystem reward claim |\n| `BORROW` | Borrowing event |\n| `REPAYMENT` | Loan repayment |\n| `RESERVES CHANGE` | Treasury reserves adjustment |\n| `REALIZED_PNL` | Realized profit or loss |\n| `NFT` | NFT mint, sale, or transfer |\n| `SPAM` | Spam/airdrop (auto-hides from accounting) |\n| `UNKNOWN` | Type not yet determined, pending review |"
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "account_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by financial account (e.g., `fac_123`)."
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by legal entity."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO-8601 start timestamp (inclusive, on `external.timestamp`)."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO-8601 end timestamp (inclusive)."
          },
          {
            "name": "source_class",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["crypto", "fiat", "exchange", "card", "other"]
            }
          },
          {
            "name": "direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["credit", "debit"]
            },
            "description": "Fund direction."
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "DEPOSIT",
                  "WITHDRAWAL",
                  "SWAP",
                  "NON_TAXABLE_CONVERSION",
                  "BRIDGE",
                  "INTERCOMPANY TRANSFER",
                  "INTERNAL TRANSFER",
                  "FEE",
                  "MINTING",
                  "STAKING_REWARD",
                  "VALIDATOR_REWARD",
                  "INVOICE",
                  "BILL",
                  "CLAIM REWARD",
                  "BORROW",
                  "REPAYMENT",
                  "RESERVES CHANGE",
                  "REALIZED_PNL",
                  "INCOME",
                  "EXPENSE",
                  "REFUND",
                  "CHARGEBACK",
                  "NFT",
                  "SPAM",
                  "UNKNOWN"
                ]
              }
            },
            "style": "form",
            "explode": true,
            "description": "Legacy alias for `transaction_types`. Filters economic transaction types, not accounting classifications. Repeat the parameter per value (e.g. ?category=a&category=b) or pass a comma-separated list."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["pending", "posted", "spam"]
            }
          },
          {
            "name": "has_posted_journal_entry",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Filter by whether a posted journal entry exists. This is journal-entry coverage, not a transaction type or accounting treatment."
          },
          {
            "name": "include_spam",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Include spam-flagged transactions in the results. Defaults to false: spam rows are EXCLUDED unless this is true. The in-app copilot passes this on every query; without it, reconciliation or account-for-every-row reads silently miss spam-flagged transactions."
          },
          {
            "name": "spam_only",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Return ONLY spam-flagged transactions (the inverse of the default exclusion). Equivalent to status=spam; do not combine with include_spam."
          },
          {
            "name": "unaccounted",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Return only transactions with no posted journal entry (convenience alias for has_posted_journal_entry=false). This filter composes with targeted id, sequence, and search filters; a targeted row with a posted entry therefore does not match."
          },
          {
            "name": "has_journal_entry",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Deprecated alias for `has_posted_journal_entry`. Despite its historical name, it filters posted-JE coverage only; it does not detect a linked DRAFT journal entry."
          },
          {
            "name": "has_accounting",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Deprecated alias for `has_posted_journal_entry`. Filter by whether a posted journal entry exists."
          },
          {
            "name": "min_value",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Minimum gross fiat VALUE filter (filters on `external.value_fiat.gross`). Decimal string, e.g., `\"100.00\"`. Canonical name for `min_amount` (kept as an alias); when both are supplied, `min_value` wins."
          },
          {
            "name": "max_value",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Maximum gross fiat VALUE filter (filters on `external.value_fiat.gross`). Decimal string, e.g., `\"10000.00\"`. Canonical name for `max_amount` (kept as an alias); when both are supplied, `max_value` wins."
          },
          {
            "name": "min_amount",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Alias of `min_value` — minimum gross fiat VALUE filter (filters on `external.value_fiat.gross`). Decimal string, e.g., `\"100.00\"`. Prefer `min_value`; this alias is retained for backward compatibility."
          },
          {
            "name": "max_amount",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Alias of `max_value` — maximum gross fiat VALUE filter (filters on `external.value_fiat.gross`). Decimal string, e.g., `\"10000.00\"`. Prefer `max_value`; this alias is retained for backward compatibility."
          },
          {
            "name": "min_quantity",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Minimum gross token QUANTITY filter — a distinct axis from fiat value, applied to the stored `grossAmount` (surfaced as `external.quantity.gross`). Decimal string, e.g., `\"0.5\"`. Note: `grossAmount` is also set on fiat/card-source transactions (where it holds the fiat amount and `external.quantity` is `null`), so those rows can also match this filter."
          },
          {
            "name": "max_quantity",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Maximum gross token QUANTITY filter — a distinct axis from fiat value, applied to the stored `grossAmount` (surfaced as `external.quantity.gross`). Decimal string, e.g., `\"100\"`. Note: `grossAmount` is also set on fiat/card-source transactions (where it holds the fiat amount and `external.quantity` is `null`), so those rows can also match this filter."
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "transaction_date",
                "created_at",
                "updated_at",
                "gross_amount",
                "net_amount",
                "gross_price",
                "asset_type",
                "chain"
              ],
              "default": "transaction_date"
            },
            "description": "Field to sort results by."
          },
          {
            "name": "sort_direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["asc", "desc"],
              "default": "desc"
            },
            "description": "Sort order. Defaults to descending (newest first)."
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 500
            },
            "description": "Free-text search across transaction hash, address, sequence number, memo, and merchant names."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more legal entity ids. Repeat the parameter per value (e.g. ?legal_entity_ids=a&legal_entity_ids=b) or pass a comma-separated list. Maximum 100 ids."
          },
          {
            "name": "transaction_ids",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more transaction ids. Repeat the parameter per value (e.g. ?transaction_ids=a&transaction_ids=b) or pass a comma-separated list. Maximum 100 ids. A targeted transaction-id or sequence search must not be combined with an unaccounted-only coverage filter."
          },
          {
            "name": "transaction_sequence_numbers",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "pattern": "^OT-\\d+$",
                "maxLength": 64
              },
              "maxItems": 100
            },
            "style": "form",
            "explode": true,
            "description": "Exact-match up to 100 transaction sequence numbers (for example OT-123). Repeat the parameter per value or pass a comma-separated list."
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more financial account ids. Repeat the parameter per value (e.g. ?financial_account_ids=a&financial_account_ids=b) or pass a comma-separated list. Maximum 100 ids."
          },
          {
            "name": "transaction_types",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "DEPOSIT",
                  "WITHDRAWAL",
                  "SWAP",
                  "NON_TAXABLE_CONVERSION",
                  "BRIDGE",
                  "INTERCOMPANY TRANSFER",
                  "INTERNAL TRANSFER",
                  "FEE",
                  "MINTING",
                  "STAKING_REWARD",
                  "VALIDATOR_REWARD",
                  "INVOICE",
                  "BILL",
                  "CLAIM REWARD",
                  "BORROW",
                  "REPAYMENT",
                  "RESERVES CHANGE",
                  "REALIZED_PNL",
                  "INCOME",
                  "EXPENSE",
                  "REFUND",
                  "CHARGEBACK",
                  "NFT",
                  "SPAM",
                  "UNKNOWN"
                ]
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more economic transaction types (e.g. `WITHDRAWAL`, `DEPOSIT`, `SWAP`). These are not accounting classifications. Do not combine with the legacy `classifications` filter."
          },
          {
            "name": "classifications",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "DEPOSIT",
                  "WITHDRAWAL",
                  "SWAP",
                  "NON_TAXABLE_CONVERSION",
                  "BRIDGE",
                  "INTERCOMPANY TRANSFER",
                  "INTERNAL TRANSFER",
                  "FEE",
                  "MINTING",
                  "STAKING_REWARD",
                  "VALIDATOR_REWARD",
                  "INVOICE",
                  "BILL",
                  "CLAIM REWARD",
                  "BORROW",
                  "REPAYMENT",
                  "RESERVES CHANGE",
                  "REALIZED_PNL",
                  "INCOME",
                  "EXPENSE",
                  "REFUND",
                  "CHARGEBACK",
                  "NFT",
                  "SPAM",
                  "UNKNOWN"
                ]
              }
            },
            "style": "form",
            "explode": true,
            "description": "Legacy alias for `transaction_types`. Filters economic transaction types, not accounting classifications. Do not combine with `transaction_types`."
          },
          {
            "name": "source_types",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more canonical source types. Repeat the parameter per value (e.g. ?source_types=WALLET&source_types=RAMP_CARD) or pass a comma-separated list. Allowed values: `WALLET`, `EXCHANGE_SOURCE`, `RAINCARD`, `STAKING_SOURCE`, `REQUEST_FINANCE`, `RAIN_BILL_PAY`, `CREDIT_COOP`, `NIURAL_SOURCE`, `FIREBLOCKS`, `BANK_SOURCE`, `RAMP_CARD`, `RAMP_BANK_ACCOUNT`, `RAMP_STATEMENT`, `RAMP_REIMBURSEMENT`, `MANUAL_BANK_STATEMENT`, `FINCH_PAYROLL`, `STRIPE`. A named institution or account is not a source type; resolve it to a financial-account/source id first."
          },
          {
            "name": "asset_types",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more asset types. Repeat the parameter per value (e.g. ?asset_types=a&asset_types=b) or pass a comma-separated list."
          },
          {
            "name": "chains",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more chains. Repeat the parameter per value (e.g. ?chains=a&chains=b) or pass a comma-separated list."
          },
          {
            "name": "addresses",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "maxItems": 100
            },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more on-chain addresses (max 100), matched case-insensitively against the transaction's from or to address (checksummed input is fine). Combines with other filters by intersection — e.g. account_id plus addresses returns only that account's transactions touching those addresses. Repeat the parameter per value (e.g. ?addresses=a&addresses=b) or pass a comma-separated list."
          },
          {
            "name": "provider_category",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter by raw provider category metadata. It does not filter accounting treatment."
          },
          {
            "name": "source_category",
            "in": "query",
            "required": false,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Deprecated alias for `provider_category`."
          },
          {
            "name": "include_count",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "When true, returns `total_count` for the complete filtered result set. Use `limit: 1, include_count: true` to get an organization-wide or scoped count instead of walking every page merely to count rows."
          },
          {
            "name": "count_only",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "When true, returns only the exact filtered `total_count` with an empty `data` array. Use this instead of requesting rows when the count is the only required result."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of transactions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Transaction"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Total rows matching all supplied filters. Present only when `include_count=true`."
                    }
                  }
                },
                "examples": {
                  "crypto": {
                    "summary": "Crypto transaction (sourceClass: crypto)",
                    "value": {
                      "data": [
                        {
                          "id": "txn_crypto456",
                          "account_id": "fac_wallet789",
                          "organization_id": "org_123abc",
                          "legal_entity_id": "le_456def",
                          "source_class": "crypto",
                          "provider": "wallet",
                          "external": {
                            "timestamp": "2024-03-09T10:15:30Z",
                            "description": "On-chain transfer",
                            "direction": "credit",
                            "quantity": {
                              "gross": "1.5",
                              "fee": "0.005",
                              "net": "1.495"
                            },
                            "currency": "ETH",
                            "value_fiat": {
                              "gross": "5250.00",
                              "fee": "12.50",
                              "net": "5237.50",
                              "currency": "USD"
                            }
                          },
                          "crypto": {
                            "chain": "eth",
                            "asset_type": "ETH",
                            "from_address": "0xAbC...",
                            "to_address": "0x71C...",
                            "hash": "0xdef987..."
                          },
                          "bank": null,
                          "card": null,
                          "provider_category": null,
                          "source_category": null,
                          "internal": {
                            "status": "posted",
                            "transaction_type": "DEPOSIT",
                            "category": "DEPOSIT",
                            "memo": null
                          }
                        }
                      ],
                      "has_more": true,
                      "next_cursor": "dHhuXzg5MF9hYmM="
                    }
                  },
                  "fiat": {
                    "summary": "Bank / Fiat transaction (sourceClass: fiat)",
                    "value": {
                      "data": [
                        {
                          "id": "txn_bank123",
                          "account_id": "fac_bank456",
                          "organization_id": "org_123abc",
                          "legal_entity_id": "le_456def",
                          "source_class": "fiat",
                          "provider": "plaid",
                          "external": {
                            "timestamp": "2024-03-09T10:15:30Z",
                            "description": "WIRE TRANSFER FROM ACME CORP",
                            "direction": "credit",
                            "quantity": null,
                            "currency": "USD",
                            "value_fiat": {
                              "gross": "50000.00",
                              "fee": "0.00",
                              "net": "50000.00",
                              "currency": "USD"
                            }
                          },
                          "crypto": null,
                          "bank": {
                            "bank_name": "Mercury",
                            "account_last4": "4521",
                            "counterparty_name": "Acme Corp"
                          },
                          "card": null,
                          "provider_category": null,
                          "source_category": null,
                          "internal": {
                            "status": "posted",
                            "transaction_type": "DEPOSIT",
                            "category": "DEPOSIT",
                            "memo": null
                          }
                        }
                      ],
                      "has_more": false,
                      "next_cursor": null
                    }
                  },
                  "card": {
                    "summary": "Card transaction (sourceClass: card)",
                    "value": {
                      "data": [
                        {
                          "id": "txn_card789",
                          "account_id": "crd_0987654321",
                          "organization_id": "org_123abc",
                          "legal_entity_id": "le_456def",
                          "source_class": "card",
                          "provider": "ramp",
                          "external": {
                            "timestamp": "2024-03-09T10:15:30Z",
                            "description": "AWS CLOUD SERVICES",
                            "direction": "debit",
                            "quantity": null,
                            "currency": "USD",
                            "value_fiat": {
                              "gross": "2340.00",
                              "fee": "0.00",
                              "net": "2340.00",
                              "currency": "USD"
                            }
                          },
                          "crypto": null,
                          "bank": null,
                          "card": {
                            "merchant_name": "Amazon Web Services",
                            "merchant_category": "Cloud Services",
                            "card_last4": "8821",
                            "employee_name": "John Doe"
                          },
                          "provider_category": "IT & Software Services:Software & apps",
                          "source_category": "IT & Software Services:Software & apps",
                          "internal": {
                            "status": "posted",
                            "transaction_type": "BILL",
                            "category": "BILL",
                            "memo": null
                          }
                        }
                      ],
                      "has_more": false,
                      "next_cursor": null
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/transactions/update": {
      "post": {
        "operationId": "bulkUpdateTransactions",
        "summary": "Bulk update transactions",
        "description": "Updates a transaction's `transaction_type` or `memo` across up to 50 transactions in one call. A transaction type (for example `WITHDRAWAL`) describes the economic event; it does **not** select an accounting treatment or create a journal entry. This is a mutating bulk operation — confirm the scope with the user before invoking.\n\n### \"Classify these transactions\" is NOT this tool\n\nWhen someone asks you to *classify* unaccounted transactions, they mean give them an accounting treatment and book it: choose a postable ledger account and create + post a journal entry for each one. That is `tools.journal_entries.create_journal_entry` followed by `tools.journal_entries.post_journal_entry`, one entry per transaction — not a type rewrite here. Setting `transaction_type` to `EXPENSE` (or any other value) books nothing, leaves every transaction exactly as unaccounted as it was, and overwrites the economic type with a value the user never asked to change. Reach for this tool only when the request is explicitly about correcting the economic type or the memo. `classification` and `category` are legacy aliases for `transaction_type`; send only one type field. Pass `idempotencyKey` to make the call replay-safe.\n\n### Closed-period protection\n\nA transaction-type change is rejected with `409` if **any** target has a posted journal entry in a closed accounting period — the WHOLE batch is refused (never partially applied) and the response names the offenders under `locked_transactions`, matching the web UI and the legacy recategorize path. Memo-only updates are descriptive and are never blocked by this guard.",
        "tags": ["Transactions"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids", "data"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed transaction IDs (`txn_`)."
                  },
                  "data": {
                    "type": "object",
                    "description": "Fields to update. Must contain at least one field.",
                    "properties": {
                      "transaction_type": {
                        "description": "Economic transaction type (closed enum — see TransactionCategory). This does not classify accounting or create a journal entry. Case-insensitive on input. Pass `null` to clear.",
                        "anyOf": [{ "$ref": "#/components/schemas/TransactionCategory" }, { "type": "null" }]
                      },
                      "classification": {
                        "description": "Legacy alias for `transaction_type`. Case-insensitive on input. Pass `null` to clear.",
                        "anyOf": [{ "$ref": "#/components/schemas/TransactionCategory" }, { "type": "null" }]
                      },
                      "category": {
                        "description": "Legacy alias for `transaction_type`. Send only one type field. Case-insensitive on input. Pass `null` to clear.",
                        "anyOf": [{ "$ref": "#/components/schemas/TransactionCategory" }, { "type": "null" }]
                      },
                      "memo": {
                        "type": ["string", "null"],
                        "description": "Free-text memo. Pass `null` to clear."
                      }
                    }
                  }
                }
              },
              "example": {
                "ids": ["txn_111", "txn_222", "txn_333"],
                "data": {
                  "transaction_type": "BILL",
                  "memo": "Annual infrastructure cost"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result. `succeeded` contains IDs that were updated; `failed` contains IDs that could not be updated with a per-item `reason`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["txn_111", "txn_222"],
                    "failed": [
                      {
                        "id": "txn_333",
                        "reason": "Transaction not found"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "description": "The batch was rejected because at least one transaction has a posted journal entry in a closed accounting period. No transaction was updated. The offenders are listed under `locked_transactions`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "object",
                      "properties": {
                        "message": { "type": "string" },
                        "locked_transactions": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "transaction_id": {
                                "type": "string",
                                "description": "Prefixed transaction id (`txn_`)."
                              },
                              "journal_sequence_number": {
                                "type": "string",
                                "description": "Sequence number of the posted journal entry (for example `JE-42`)."
                              },
                              "accounting_period_name": {
                                "type": "string",
                                "description": "Name of the closed accounting period holding the entry."
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "error": {
                    "message": "Cannot change transaction type for 1 transaction(s) whose posted journal entries are in a closed accounting period.",
                    "locked_transactions": [
                      {
                        "transaction_id": "txn_333",
                        "journal_sequence_number": "JE-42",
                        "accounting_period_name": "Q1 2024"
                      }
                    ]
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/transactions/delete": {
      "post": {
        "operationId": "bulkDeleteTransactions",
        "summary": "Bulk delete transactions",
        "description": "Deletes up to 50 transactions in one call as a DESTRUCTIVE soft-delete. Transactions with posted journal entries cannot be deleted — unpost the JE first via `tools.journal_entries.unpost_journal_entry`. Partial-success semantics — successfully deleted IDs are returned alongside rejections. For removal of one transaction use `tools.transactions.delete_transaction`; to mark transactions as spam without deleting use `tools.transactions.bulk_mark_spam_transactions`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Transactions"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed transaction IDs (`txn_`)."
                  }
                }
              },
              "example": {
                "ids": ["txn_111", "txn_222"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result. Returns `succeeded` and `failed` arrays.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["txn_111", "txn_222"],
                    "failed": []
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/transactions/mark-spam": {
      "post": {
        "operationId": "bulkMarkSpamTransactions",
        "summary": "Mark or unmark transactions as spam",
        "description": "Marks or unmarks up to 50 transactions as spam in one call. When marking (`spam: true`): rejects transactions that already have posted journal entries (unpost the JE first via `tools.journal_entries.unpost_journal_entry`), soft-deletes associated assets, reverses disposals, and hides the transactions from accounting views. When unmarking (`spam: false`): restores transactions to their previous state — only works on transactions currently marked as spam. For permanent deletion use `tools.transactions.delete_transaction`. Partial-success semantics. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Transactions"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids", "spam"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed transaction IDs (`txn_`)."
                  },
                  "spam": {
                    "type": "boolean",
                    "description": "`true` to mark as spam, `false` to unmark."
                  }
                }
              },
              "examples": {
                "mark": {
                  "summary": "Mark as spam",
                  "value": {
                    "ids": ["txn_111", "txn_222"],
                    "spam": true
                  }
                },
                "unmark": {
                  "summary": "Unmark spam",
                  "value": {
                    "ids": ["txn_333"],
                    "spam": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["txn_111"],
                    "failed": [
                      {
                        "id": "txn_222",
                        "reason": "Transaction has a posted journal entry -- void it first"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/transactions/{id}": {
      "get": {
        "operationId": "getTransaction",
        "summary": "Get a transaction",
        "description": "Returns a single transaction by its prefixed `txn_` ID — direction, fiat / native amounts, source, transaction type, provider category, linked journal entry, and tags. The response shape matches one row from `tools.transactions.list_transactions`. Read-only.",
        "tags": ["Transactions"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Transaction found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transaction"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateTransaction",
        "summary": "Update a transaction",
        "description": "Updates `transaction_type` or `memo` on a single transaction. Transaction type describes the economic event and does not select an accounting treatment or create a journal entry. `classification` and `category` are legacy aliases — send only one type field.",
        "tags": ["Transactions"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "transaction_type": {
                    "description": "Economic transaction type (closed enum — see TransactionCategory). This does not classify accounting or create a journal entry. Case-insensitive on input. Pass `null` to clear.",
                    "anyOf": [{ "$ref": "#/components/schemas/TransactionCategory" }, { "type": "null" }]
                  },
                  "category": {
                    "description": "Legacy alias for `transaction_type`. Case-insensitive on input. Pass `null` to clear.",
                    "anyOf": [{ "$ref": "#/components/schemas/TransactionCategory" }, { "type": "null" }]
                  },
                  "classification": {
                    "description": "Legacy alias for `transaction_type`. Send only one type field. Case-insensitive on input. Pass `null` to clear.",
                    "anyOf": [{ "$ref": "#/components/schemas/TransactionCategory" }, { "type": "null" }]
                  },
                  "memo": {
                    "type": ["string", "null"],
                    "description": "Free-text memo. Pass `null` to clear."
                  }
                }
              },
              "example": {
                "transaction_type": "BILL",
                "memo": "Annual infrastructure cost"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Transaction updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transaction"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteTransaction",
        "summary": "Delete a transaction",
        "description": "Deletes a single transaction as a DESTRUCTIVE soft-delete. Rejects when the transaction has posted journal entries — unpost the JE first via `tools.journal_entries.unpost_journal_entry`. To mark as spam instead of deleting use `tools.transactions.bulk_mark_spam_transactions`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Transactions"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "204": {
            "description": "Transaction deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Transaction has posted journal entries.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": {
                    "code": "CONFLICT",
                    "message": "Transaction has been posted and cannot be deleted",
                    "request_id": "req_5kDsQmN1vBpLwT"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/ledger-accounts": {
      "get": {
        "operationId": "listLedgerAccounts",
        "summary": "List ledger accounts",
        "description": "Returns the Chart of Accounts in chart-of-accounts order (`sequence_number` ascending — the default sort; sortable only by `sequence` or `name`, not by date). Accounts form a parent / child hierarchy via `parent_account_id`. Only child accounts (`is_postable: true`) can receive journal-entry lines — parent accounts are grouping containers.\n\nFor full enumeration set `limit: 100` (the max enforced by the schema).\n\n### QuickBooks-default carryover\n\nMany orgs have 200+ accounts shipped but only ~40 with actual current-year activity (vehicle expenses, multiple bank sub-accounts, dormant income lines, etc.). Do not treat every account as in-use. When picking an account for posting / classification:\n\n- Filter to accounts with recent activity (cross-reference `GET /v1/balances` for period activity).\n- Or ask the user for the canonical target account.\n\n### `sequence_number` conventions\n\nA common (not enforced) chart-of-accounts ordering. Not every environment populates it, and some legacy/auto-created accounts carry no meaningful value — `sequence_number` may be `null`. Do not assume the range below holds for every account or that the field is always present.\n\n- `1xxxx` — Assets\n- `2xxxx` — Liabilities\n- `3xxxx` — Equity\n- `4xxxx` — Income\n- `5xxxx` — Expenses\n\n### `normal_balance`\n\nComputed from `type`:\n\n- `Asset` / `Expense` → `debit`\n- `Liability` / `Equity` / `Income` → `credit`\n\n### total_count is filter-scoped\n\nWith include_count=true, the returned count equals the org-wide Chart-of-Accounts count ONLY when no filters (ledger_account_types, is_postable, is_clearing_account, search, parent_ledger_account_ids, or id filters) are applied. A filtered count is the size of the filtered set, NOT the whole CoA: do not report it as the org account total.\n\n### Archived divergence from the dashboard CoA\n\nThis endpoint hides archived accounts by default (include_archived defaults to false), whereas the dashboard Chart of Accounts includes them. For a read that matches what the user sees in the dashboard CoA, pass include_archived=true.",
        "tags": ["Ledger Accounts"],
        "x-mint": {
          "content": "## Account Hierarchy\n\nAccounts form a tree. Parent accounts are grouping containers; child accounts are postable.\n\n```\nAssets (parent)\n+-- Cash & Bank (parent)\n|   +-- 11000 - Mercury Checking  <- child (postable)\n|   +-- 11001 - USDC Treasury     <- child (postable)\n+-- Crypto Holdings (parent)\n|   +-- 12000 - ETH Holdings      <- child (postable)\n|   +-- 12001 - BTC Holdings      <- child (postable)\nLiabilities (parent)\n+-- 21000 - Accounts Payable      <- child (postable)\n+-- 21001 - Accrued Expenses      <- child (postable)\nEquity (parent)\n+-- 31000 - Retained Earnings     <- child (postable)\nIncome (parent)\n+-- 41000 - Trading Revenue       <- child (postable)\n+-- 41001 - Staking Income        <- child (postable)\nExpenses (parent)\n+-- 51000 - Gas Fees              <- child (postable)\n+-- 51001 - Software & SaaS       <- child (postable)\n```"
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "ledger_account_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated account types. Allowed: `Asset`, `Liability`, `Equity`, `Income`, `Expense`. Earlier versions of this spec advertised singular `?type=` which v1 never actually accepted. The generated MCP tool auto-forwards `type` → `ledger_account_types` for backward-compat; passing both at once returns a 400 with a clear migration hint.",
            "x-deprecated-aliases": ["type"]
          },
          {
            "name": "is_postable",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "`true` for postable child accounts only, `false` for parent grouping accounts only."
          },
          {
            "name": "include_archived",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, includes ledger accounts with `is_archived: true` in the results. Defaults to `false` — the agent's default Chart of Accounts view hides dormant rows so dropdowns and search stay clean. Useful for audit views; pair with the `is_archived` field on each row to see which are archived. Distinct from `is_deleted` (soft-delete) which is hidden unconditionally."
          },
          {
            "name": "is_clearing_account",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Filter to clearing accounts (`true`) or non-clearing accounts (`false`)."
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["sequence", "name"]
            },
            "description": "Field to sort by. Defaults to `sequence` (chart-of-accounts order)."
          },
          {
            "name": "sort_direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["asc", "desc"]
            },
            "description": "Sort direction."
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Case-insensitive substring match on the account name."
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, includes a total `count` in the response. Opt-in — counting is skipped by default to keep list calls cheap."
          },
          {
            "name": "ledger_account_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter to specific `lac_`-prefixed ledger account IDs (repeat the param per id)."
          },
          {
            "name": "parent_ledger_account_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter to children of the given `lac_`-prefixed parent account IDs (repeat the param per id)."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of ledger accounts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LedgerAccount"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "lac_789abc",
                      "name": "Mercury Checking",
                      "type": "Asset",
                      "normal_balance": "debit",
                      "sequence_number": 11000,
                      "parent_account_id": "lac_111aaa",
                      "is_postable": true,
                      "is_clearing_account": false,
                      "asset_type": "USD",
                      "created_at": "2023-01-15T08:00:00Z"
                    },
                    {
                      "id": "lac_111aaa",
                      "name": "Cash & Bank",
                      "type": "Asset",
                      "normal_balance": "debit",
                      "sequence_number": 10000,
                      "parent_account_id": null,
                      "is_postable": false,
                      "is_clearing_account": false,
                      "asset_type": null,
                      "created_at": "2023-01-15T08:00:00Z"
                    }
                  ],
                  "has_more": false,
                  "next_cursor": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createLedgerAccount",
        "summary": "Create a ledger account",
        "description": "Creates a new organization-scoped ledger account (chart-of-accounts entry).\n\n### Required body fields\n\n- `name` — display name.\n- `type` — one of `Asset`, `Liability`, `Equity`, `Income`, `Expense`.\n- `legal_entity_id` — prefixed `le_*`. Validated but NOT stored: ledger accounts are org-scoped, so this value is accepted for forward-compatibility and otherwise ignored.\n\n### Optional fields\n\n- `sequence_number` — the numeric code shown in reports (e.g. `141000`). When omitted, defaults to the highest existing sequence for the same `type` plus one (appended to the end of that type's block); pass an explicit value to control ordering.\n- `parent_account_id` — prefixed `lac_*`. Makes the new account a child of an existing parent. Parent accounts are containers and themselves cannot receive journal entries directly.\n- `is_clearing_account` — boolean. Defaults to `false`. Flags this row as a clearing / suspense account.\n- `asset_type` — token or currency symbol such as `ETH` or `USD`. Annotates the account's tracked currency. Always optional, the handler does not require it even when `type: Asset`.\n\nPass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "type", "legal_entity_id"],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name (e.g., `Mercury Checking`)."
                  },
                  "type": {
                    "type": "string",
                    "enum": ["Asset", "Liability", "Equity", "Income", "Expense"]
                  },
                  "sequence_number": {
                    "type": "integer",
                    "description": "Account number in the chart of accounts."
                  },
                  "parent_account_id": {
                    "type": "string",
                    "description": "Prefixed parent account ID. Omit for top-level."
                  },
                  "is_clearing_account": {
                    "type": "boolean",
                    "description": "Flags this row as a clearing / suspense account. Defaults to `false`."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed `le_*`. Validated but not stored — ledger accounts are org-scoped, so this value is accepted for forward-compatibility and ignored."
                  },
                  "asset_type": {
                    "type": "string",
                    "description": "Token or currency type (e.g., `ETH`, `USD`)."
                  }
                }
              },
              "example": {
                "name": "ETH Holdings",
                "type": "Asset",
                "sequence_number": 12000,
                "parent_account_id": "lac_crypto_group",
                "legal_entity_id": "le_456def",
                "asset_type": "ETH"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Ledger account created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedgerAccount"
                },
                "example": {
                  "id": "lac_789abc",
                  "name": "ETH Holdings",
                  "type": "Asset",
                  "normal_balance": "debit",
                  "sequence_number": 12000,
                  "parent_account_id": "lac_crypto_group",
                  "is_postable": true,
                  "is_clearing_account": false,
                  "asset_type": "ETH",
                  "created_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/ledger-accounts/{id}": {
      "get": {
        "operationId": "getLedgerAccount",
        "summary": "Get a ledger account",
        "description": "Retrieves a single ledger account by its prefixed ID. Returns the same shape as a list response item.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Ledger account found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedgerAccount"
                },
                "example": {
                  "id": "lac_789abc",
                  "name": "Mercury Checking",
                  "type": "Asset",
                  "normal_balance": "debit",
                  "sequence_number": 11000,
                  "parent_account_id": "lac_111aaa",
                  "is_postable": true,
                  "is_clearing_account": false,
                  "asset_type": "USD",
                  "created_at": "2023-01-15T08:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateLedgerAccount",
        "summary": "Update a ledger account",
        "description": "Updates mutable metadata on a ledger account. Mutable fields: `name`, `sequence_number`, `parent_account_id` (reparents the account, with cycle- and depth-guards), `is_clearing_account`, and `asset_type`. Only `type` is immutable, to preserve double-entry integrity. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New display name."
                  },
                  "sequence_number": {
                    "type": "integer",
                    "description": "New chart-of-accounts sequence number."
                  },
                  "parent_account_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `lac_` parent account id to reparent under, or null to move to top level. Cycle- and depth-checked."
                  },
                  "is_clearing_account": {
                    "type": "boolean",
                    "description": "Flags this row as a clearing / suspense account."
                  },
                  "asset_type": {
                    "type": "string",
                    "description": "Token or currency symbol the account tracks (e.g. `ETH`, `USD`)."
                  }
                }
              },
              "example": {
                "name": "ETH Cold Storage"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ledger account updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedgerAccount"
                },
                "example": {
                  "id": "lac_789abc",
                  "name": "ETH Cold Storage",
                  "type": "Asset",
                  "normal_balance": "debit",
                  "sequence_number": 12000,
                  "parent_account_id": "lac_crypto_group",
                  "is_postable": true,
                  "is_clearing_account": false,
                  "asset_type": "ETH",
                  "created_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteLedgerAccount",
        "summary": "Delete a ledger account",
        "description": "Deletes a single ledger account as a DESTRUCTIVE soft-delete. Rejects (`409 CONFLICT`) when the account has posted journal entry lines or a non-zero balance; parent accounts cannot be deleted while they still have child accounts. To remove many accounts at once use `tools.ledger_accounts.bulk_delete_ledger_accounts`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "204": {
            "description": "Ledger account deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/ledger-accounts/delete": {
      "post": {
        "operationId": "bulkDeleteLedgerAccounts",
        "summary": "Bulk delete ledger accounts",
        "description": "Deletes up to 50 ledger accounts in one call as a DESTRUCTIVE soft-delete. Each account is independently checked for references (posted journal entry lines, asset lots, wallet links, exchange sources) before deletion; accounts with references are rejected individually while the rest of the batch proceeds (partial-success semantics). Parent accounts cascade — the entire subtree is reference-checked. Pass `idempotencyKey` to make the call replay-safe.\n\n### Safe operation workflow\n\n1. Call once with `dry_run: true` to preview the exact set — the response returns `{ dry_run, summary: { requested, eligible, failed }, eligible: [...], failed: [...] }` and changes nothing.\n2. Re-call without `dry_run`, setting `confirm_count` to the number of distinct ids you intend to affect; the request is rejected (400) if it does not equal the distinct id count.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed ledger account IDs (`lac_`)."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the number of distinct ids in the request, otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns the eligible/failed preview without mutating anything. Use to preview the exact scope before committing."
                  }
                }
              },
              "example": {
                "ids": ["lac_111aaa", "lac_222bbb"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["lac_111aaa"],
                    "failed": [
                      {
                        "id": "lac_222bbb",
                        "reason": "Cannot delete — account has posted journal entry lines"
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries": {
      "get": {
        "operationId": "listJournalEntries",
        "summary": "List journal entries",
        "description": "Returns double-entry bookkeeping records with their derived lines, in reverse-chronological order by `accounting_date`.\n\nSupports filtering by `legal_entity_id`, `transaction_id`, `template_id`, date range, and tags. For full enumeration set `limit: 100` (the max enforced by the schema); the default of 50 is sized for interactive lookups.\n\n### Filtering\n\n- **Multi-value status**: pass `?statuses=draft,posted` (comma-separated, plural). Singular `status` is a deprecated alias — the generated MCP tool auto-forwards `status` → `statuses` for backward-compat; passing both at once returns a 400 with a clear migration hint.\n- **Sync status**: `?sync_status=pending` finds JEs not yet pushed to QuickBooks; `?sync_status=synced` returns the inverse; `?sync_status=failed` returns entries whose latest sync attempt errored. Narrow failures with the comma-separated `?sync_error_types=UNKNOWN,VALIDATION` (also `DUPLICATE_DETECTED`, `DOC_NUMBER_COLLISION`, `MAPPING_MISSING`, `AUTHENTICATION`) — e.g. `?sync_status=failed&sync_error_types=UNKNOWN&from=2026-05-29&to=2026-05-31`.\n\n### Status reference\n\n- `DRAFT` — mutable, deletable.\n- `POSTED` — locked, reflected in balances; can be unposted, voided, or reversed.\n- `REVERSED` — zeroed by a mirror entry.\n- `UNPOSTED` — was posted, then unposted by an admin and balances rolled back. Distinct from `DRAFT`.\n- `IN_PROGRESS` — system balance recalculation running; read-only.\n- `ERROR` — system error during posting; requires re-posting.\n\n### Resolving customer-facing IDs\n\nThe `id` filter accepts the internal `je_*` prefixed ID only. To look up an entry by its customer-facing sequence (`JE-2096`), call `GET /v1/journal-entries/by-sequence/{sequence_number}` — an indexed O(1) direct lookup. Paginate this list endpoint only when you don't have the exact sequence (for example, the most-recent posted JE for a given legal entity). Results are most-recent first, so `JE-2104 → JE-2103 → …` walking backwards.\n\n### Notes\n\n- `posted_at` is `null` for `DRAFT` entries.\n- The `lines` array is always returned — you never need to construct it yourself.\n- Response is double-nested: `{ data: { data: [...], has_more, next_cursor } }`.\n- `sequence_number` (`JE-NNNN`) is allocated at JE creation, NOT at `accounting_date` — backdated entries get higher sequence numbers. Always sort by `accounting_date` for chronological views; trusting `OT-`/`JE-` numeric order is wrong.\n- For sync-to-QBO status, prefer the `?sync_status=` filter. The 168-hour duplicate-detection window is exposed via `GET /v1/gl-sync/duplicates/recent`; per-entry sync history lives at `GET /v1/gl-sync/entries/{id}`.\n\nThese are ENTENDRE-side ledger records — do not describe them as coming directly from the connected GL. For provider-native journal / GL reads, use `tools.gl.raw_glread` or `tools.gl.search_glrecords`. (Mirrors copilot journal-entry/index.ts:58,74.)\n\n### Always filter: cost guard\n\nNever pull all journal entries. Narrow by journal_sequence_numbers / transaction_sequence_numbers when a number is known, otherwise by a date range or ledger_account_ids. Even a 'show all journals' request needs a date range.\n\n### sync_status vs the synced-audit filters\n\nsync_status=synced and sync_status=pending CANNOT be combined with synced_start_date, synced_end_date, or sync_history_ids: v1 returns a 400. Those audit filters match on WHEN an entry was synced and intentionally include entries later unsynced, which contradicts the current-state synced/pending flag. sync_status=failed is the allowed exception (it filters on the recorded error, not the current sync flag). If you need both axes, drop sync_status.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "statuses",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated status values. Allowed: `draft`, `posted`, `reversed`, `in_progress`, `error`, `unposted`. Earlier versions of this spec advertised singular `?status=` which v1 never actually accepted. The generated MCP tool auto-forwards `status` → `statuses` for backward-compat; passing both at once returns a 400 with a clear migration hint.",
            "x-deprecated-aliases": ["status"]
          },
          {
            "name": "transaction_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by linked transaction."
          },
          {
            "name": "template_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by generating template (`tpl_` prefix)."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Filters on `accounting_date`."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "tag_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated `tag_` IDs."
          },
          {
            "name": "sync_status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["synced", "pending", "failed"]
            },
            "description": "Filter by current/latest GL sync state. `synced` = pushed to the GL provider; `pending` = not yet synced (never attempted or queued); `failed` = the latest sync attempt recorded an error. This is an outbound journal-entry state, not inbound GL-import freshness. Use `sync_error_types` to narrow `failed` to specific categories."
          },
          {
            "name": "sync_error_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated GL sync error categories (case-insensitive). One or more of `UNKNOWN`, `DUPLICATE_DETECTED`, `DOC_NUMBER_COLLISION`, `MAPPING_MISSING`, `VALIDATION`, `AUTHENTICATION`. Implies `sync_status=failed`; cannot be combined with `sync_status=synced` or `sync_status=pending` — that returns a 400. Example: `?sync_status=failed&sync_error_types=UNKNOWN,VALIDATION`."
          },
          {
            "name": "sync_attempt_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter journal entries whose current `latest_gl_sync_attempt.id` matches a supplied `jesa_…` ID. Older attempt IDs return no rows; use `journal_entry_ids` for ordinary JE lookup."
          },
          {
            "name": "source_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated source types to include (`$in`). One or more of `TRANSACTION`, `ASSETS`, `REVALUATION`, `REVERSE_REVALUATION`, `NIURAL_INVOICE`, `ACCRUAL`, `MANUAL`, `BILL_EXPENSE`, `QUICKBOOKS`, `STRIPE_INVOICE`, `STRIPE_PAYMENT`, `STRIPE_FEE`, `STRIPE_DISPUTE`, `CASH_APPLICATION`. Cannot be combined with `exclude_source_types` (returns 400)."
          },
          {
            "name": "journal_sequence_numbers",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated journal sequence numbers for exact match (`JE-2096` or a bare integer). For a single lookup prefer `GET /v1/journal-entries/by-sequence/{sequence_number}`."
          },
          {
            "name": "transaction_sequence_numbers",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated source transaction sequence numbers (e.g. `OT-1234`); resolved to the linked entries server-side."
          },
          {
            "name": "exclude_source_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated source types to exclude (`$nin`). One or more of `TRANSACTION`, `ASSETS`, `REVALUATION`, `REVERSE_REVALUATION`, `NIURAL_INVOICE`, `ACCRUAL`, `MANUAL`, `BILL_EXPENSE`, `QUICKBOOKS`, `STRIPE_INVOICE`, `STRIPE_PAYMENT`, `STRIPE_FEE`, `STRIPE_DISPUTE`, `CASH_APPLICATION`. Cannot be combined with `source_types` (returns 400)."
          },
          {
            "name": "updated_since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Incremental-sync (CDC) filter: return entries whose `updated_at` is at or after this ISO-8601 timestamp (inclusive). Use for polling changes since your last sync. Distinct from `start_date`/`end_date`, which bound `accounting_date`."
          },
          {
            "name": "synced_start_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "Inclusive lower bound on `last_synced_at` — the durable sync-audit stamp that survives unsync. Matches entries synced in the window even if unsynced afterwards (PENG-1988)."
          },
          {
            "name": "synced_end_date",
            "in": "query",
            "schema": { "type": "string", "format": "date-time" },
            "description": "Inclusive upper bound on `last_synced_at`. Must be on or after synced_start_date."
          },
          {
            "name": "sync_history_ids",
            "in": "query",
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string", "pattern": "^sh_[0-9a-fA-F]{24}$" } },
            "description": "Members of specific sync batches (`sh_…` ids from GET /gl-sync/history), matched on the durable batch stamp."
          },
          {
            "name": "created_since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Return entries whose `created_at` is at or after this ISO-8601 timestamp (inclusive)."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "include_count",
            "in": "query",
            "required": false,
            "schema": { "type": "boolean" },
            "description": "When true, returns `total_count` for the complete filtered result set. Use `limit: 1, include_count: true` for organization-wide or scoped counts such as current `MAPPING_MISSING` failures instead of walking every page merely to count rows."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more legal entity ids. Repeat the parameter per value (e.g. ?legal_entity_ids=a&legal_entity_ids=b) or pass a comma-separated list."
          },
          {
            "name": "journal_entry_ids",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more journal entry ids. Repeat the parameter per value (e.g. ?journal_entry_ids=a&journal_entry_ids=b) or pass a comma-separated list."
          },
          {
            "name": "ledger_account_ids",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more exact `lac_` ledger-account IDs referenced by JE lines. Repeat the parameter per value or pass a comma-separated list. Use IDs rather than account names when duplicate names exist. For posted exposure on duplicated accounts, pass the duplicate IDs with `statuses: ['posted']`, paginate every returned page, and sum only lines whose `ledger_account_id` exactly matches the target ID."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more accounting period ids. Repeat the parameter per value (e.g. ?accounting_period_ids=a&accounting_period_ids=b) or pass a comma-separated list."
          },
          {
            "name": "template_ids",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more template ids. Repeat the parameter per value (e.g. ?template_ids=a&template_ids=b) or pass a comma-separated list."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of journal entries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/JournalEntry"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Total rows matching all supplied filters. Present only when `include_count=true`."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "je_abc123",
                      "sequence_number": "JE-42",
                      "status": "posted",
                      "originated_by": "system",
                      "accounting_date": "2024-03-09T00:00:00Z",
                      "posted_at": "2024-03-09T10:30:00Z",
                      "memo": "ETH inflow from client",
                      "legal_entity_id": "le_456def",
                      "transaction_id": "txn_8923749823",
                      "template_id": "tpl_789xyz",
                      "classification": "DEPOSIT",
                      "tag_ids": ["tag_456ghi"],
                      "reversal_chain": {
                        "previous_entry_id": null,
                        "next_entry_id": null
                      },
                      "lines": [
                        {
                          "id": "jel_def456",
                          "ledger_account_id": "lac_789abc",
                          "legal_entity_id": "le_456def",
                          "credit_or_debit": "DEBIT",
                          "amount": "5250.00",
                          "currency": "USD",
                          "memo": "ETH inflow",
                          "tag_ids": []
                        },
                        {
                          "id": "jel_ghi789",
                          "ledger_account_id": "lac_222bbb",
                          "legal_entity_id": "le_456def",
                          "credit_or_debit": "CREDIT",
                          "amount": "5250.00",
                          "currency": "USD",
                          "memo": "ETH inflow",
                          "tag_ids": []
                        }
                      ],
                      "created_at": "2024-03-09T10:30:00Z",
                      "updated_at": "2024-03-09T10:30:00Z"
                    }
                  ],
                  "has_more": true,
                  "next_cursor": "amVfOTAx"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createJournalEntry",
        "summary": "Create a journal entry",
        "description": "Creates a journal entry from a transaction, target ledger account, and transaction type. The target `ledger_account_id` is the accounting classification/treatment; `transaction_type` (for example `WITHDRAWAL`) only describes the economic event. The system derives the double-entry automatically by reading the transaction's fiat value, direction, and source type. Most transactions book a 2-line entry; a non-stablecoin crypto OUTFLOW is a disposal and books the 4-line shape — the 2-line pair plus a cost-basis adjustment and the realized gain/loss — identical to the entry the copilot's classify auto-post books.\n\n### Posting rules\n\n- **Outbound** (`debit`) transactions post `DR Target account, CR Payment`.\n- **Inbound** (`credit`) transactions post `CR Target account, DR Payment`.\n- The offsetting payment account is auto-resolved from the transaction's source wallet / bank.\n- Line amounts come from the transaction's gross fiat value.\n\n### Lifecycle\n\nReturns the entry in `DRAFT` status — call `POST /v1/journal-entries/{id}/post` to commit, or pass `auto_post: true` to post in the same request.\n\n### Classifying an unaccounted transaction\n\nThis tool plus `tools.journal_entries.post_journal_entry` IS the classify-and-book path for a transaction that `tools.transactions.list_transactions` returned with `has_posted_journal_entry=false`. Do not answer a \"classify these\" request by rewriting `transaction_type` on `tools.transactions.bulk_update_transactions` — that changes the economic label and books nothing.\n\nChoosing the target account:\n\n- It must be postable. `tools.ledger_accounts.list_ledger_accounts` returns `is_postable`; a parent/header account (`is_postable: false`) is a grouping container and the GL rejects a line against it. Pick a postable leaf under that header instead.\n- Skip archived accounts (`is_archived: true`).\n- Account NAMES repeat in a real chart — the same label can exist as a non-postable header and as one or more postable leaves. Select by `id`, never by name, and never assume one name resolves to one account.\n- If the org syncs to a GL, confirm the chosen account has a mapping via `tools.gl.list_glaccount_mappings` before booking a batch. An unmapped account still posts inside Entendre but cannot reach QuickBooks / Xero / NetSuite, so the books diverge silently.\n\n### Crypto disposals\n\nA non-stablecoin crypto outflow requires cost basis to be computed first (run the cost basis job) and a Realized Gain/Loss account in the chart of accounts — the request fails with `400` naming the missing prerequisite otherwise. A zero gain/loss disposal books the plain 2-line shape.\n\nPass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["transaction_id", "ledger_account_id"],
                "description": "Provide exactly one of `transaction_type` (canonical) or `classification` (legacy).",
                "oneOf": [
                  {
                    "required": ["transaction_type"],
                    "not": { "required": ["classification"] }
                  },
                  {
                    "required": ["classification"],
                    "not": { "required": ["transaction_type"] }
                  }
                ],
                "properties": {
                  "transaction_id": {
                    "type": "string",
                    "description": "The transaction to classify. The system reads the transaction's fiat value, direction, source type, and asset type to determine the JE shape and line amounts."
                  },
                  "transaction_type": {
                    "allOf": [{ "$ref": "#/components/schemas/TransactionCategory" }],
                    "description": "Economic transaction type (for example `WITHDRAWAL`). This is not the accounting classification; `ledger_account_id` selects that treatment."
                  },
                  "classification": {
                    "allOf": [{ "$ref": "#/components/schemas/TransactionCategory" }],
                    "description": "Legacy alias for `transaction_type`. Send only one type field."
                  },
                  "ledger_account_id": {
                    "type": "string",
                    "description": "Target child ledger account. Offsetting line auto-resolved by system."
                  },
                  "accounting_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "ISO-8601 date override. Defaults to transaction date."
                  },
                  "memo": {
                    "type": "string"
                  },
                  "tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Tag IDs for reporting segmentation."
                  },
                  "auto_post": {
                    "type": "boolean",
                    "description": "Post the entry in the same request instead of returning it in DRAFT. Defaults to `false`. A non-stablecoin crypto disposal posted this way books the 4-line realized gain/loss shape, identical to the copilot's classify auto-post."
                  },
                  "payment_account_id": {
                    "type": "string",
                    "description": "Override for the offsetting payment account (a `lac_`-prefixed ledger account id). Defaults to the ledger account linked to the transaction's source wallet / bank; pass this when that link is missing or you need a specific account."
                  },
                  "vendor_name": {
                    "type": "string",
                    "description": "Convenience field: find-or-create a SUPPLIER tag for this vendor and attach it to the entry, matching the copilot's classification behavior."
                  }
                }
              },
              "example": {
                "transaction_id": "txn_8923749823",
                "transaction_type": "DEPOSIT",
                "ledger_account_id": "lac_789abc",
                "accounting_date": "2024-03-31T00:00:00Z",
                "memo": "ETH inflow from client",
                "tag_ids": ["tag_456ghi"],
                "auto_post": false
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Journal entry created in DRAFT status, or POSTED when `auto_post` is true.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_new456",
                  "sequence_number": "JE-43",
                  "status": "draft",
                  "originated_by": "api",
                  "accounting_date": "2024-03-31T00:00:00Z",
                  "posted_at": null,
                  "memo": "ETH inflow from client",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "classification": "DEPOSIT",
                  "tag_ids": ["tag_456ghi"],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_aaa111",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_bbb222",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/UnbalancedJournal"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/manual": {
      "post": {
        "operationId": "createManualJournalEntry",
        "summary": "Create a manual multi-line journal entry",
        "description": "Creates a free-form journal entry from explicit, balanced lines — the manual-bookkeeping counterpart to `POST /v1/journal-entries` (which derives a 2-line entry from a transaction, its transaction type, and a target ledger account). Use this for accruals, true-ups, opening balances, and any entry whose lines you construct yourself.\n\n### Line rules\n\n- 2–500 lines; total debits must equal total credits **exactly** (no tolerance — compute amounts with decimal math, not floats).\n- At least one `debit` and one `credit` line.\n- `amount` accepts a JSON number or a decimal string (`\"125.50\"`). **Strings are recommended** — they are precision-safe and match the format the API returns.\n- `credit_or_debit` is case-insensitive on input (`debit` / `DEBIT`); lowercase is canonical.\n- The same ledger account may appear on multiple lines. Every account must be a postable **leaf** account, not archived.\n- At most one tag per tag key type per line (e.g. one `Customer` tag).\n\n### Memo discipline\n\nAny line over $50,000 requires an audit anchor: a linked `transaction_id`, OR an entry-level `memo` of ≥ 10 chars, OR a ≥ 10-char `memo` on that line.\n\n### Linked transaction (optional)\n\n`transaction_id` links the entry to a transaction that does **not** already have accounting — if it does, this returns `409` (classify-then-adjust flows should omit `transaction_id`). When linked, `accounting_date` and the legal entity default from the transaction.\n\n### Legal entity\n\nResolution order: explicit `legal_entity_id` → the linked transaction's entity → the organization's single ACTIVE entity. Organizations with **multiple** active entities must pass `legal_entity_id` explicitly (400 otherwise).\n\n### Lifecycle\n\n`status: \"draft\"` (default) returns a `DRAFT` entry — commit later via `POST /v1/journal-entries/{id}/post`. `status: \"posted\"` creates and posts in one call; if posting fails the entry is preserved as `DRAFT` and the `500` error carries its `journal_entry_id` and a `posting_error` detail (the message itself is the generic 5xx string) so you can retry posting without re-creating.\n\n### Retry safety\n\nManual creates are **not** deduplicated server-side — each attempt allocates a fresh entry. A linked `transaction_id` is backstopped by the one-entry-per-transaction `409` guard, but a **standalone** create (no `transaction_id`) retried after a `5xx` (including the `status:\"posted\"` post-failure path) will create a **second draft**. Send an `Idempotency-Key` header on any retryable caller to make the whole request replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["lines"],
                "properties": {
                  "lines": {
                    "type": "array",
                    "minItems": 2,
                    "maxItems": 500,
                    "description": "Explicit journal entry lines. Total debits must equal total credits exactly.",
                    "items": {
                      "type": "object",
                      "required": ["ledger_account_id", "amount", "credit_or_debit"],
                      "properties": {
                        "ledger_account_id": {
                          "type": "string",
                          "description": "Postable (leaf, non-archived) ledger account (`lac_` prefix)."
                        },
                        "amount": {
                          "oneOf": [
                            {
                              "type": "string"
                            },
                            {
                              "type": "number"
                            }
                          ],
                          "description": "Positive line amount. Decimal string (`\"125.50\"`) recommended for exactness; JSON numbers are accepted but subject to float representation."
                        },
                        "credit_or_debit": {
                          "type": "string",
                          "enum": ["debit", "credit"],
                          "description": "Line direction. Case-insensitive on input; lowercase canonical."
                        },
                        "memo": {
                          "type": "string",
                          "description": "Line-level memo. A ≥ 10-char line memo satisfies memo discipline for this line."
                        },
                        "tag_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "description": "Tag IDs for this line (`tag_` prefix). Max one tag per key type per line."
                        }
                      }
                    }
                  },
                  "status": {
                    "type": "string",
                    "enum": ["draft", "posted"],
                    "default": "draft",
                    "description": "`posted` creates and posts in one call (same side effects as posting a draft: balances, assets, GL sync)."
                  },
                  "transaction_id": {
                    "type": "string",
                    "description": "Optional transaction link (`txn_` prefix). The transaction must not already have accounting (409 otherwise). Supplies default `accounting_date` and legal entity."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Legal entity for the entry (`le_` prefix). Required when the organization has multiple ACTIVE legal entities and no `transaction_id` is linked."
                  },
                  "accounting_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "ISO-8601 accounting date. Required when no `transaction_id` is linked; otherwise defaults to the transaction date. Must fall in an open accounting period."
                  },
                  "memo": {
                    "type": "string",
                    "description": "Entry-level memo. ≥ 10 chars satisfies memo discipline for all lines."
                  }
                }
              },
              "example": {
                "lines": [
                  {
                    "ledger_account_id": "lac_789abc",
                    "amount": "1200.00",
                    "credit_or_debit": "debit",
                    "memo": "March office rent",
                    "tag_ids": ["tag_456ghi"]
                  },
                  {
                    "ledger_account_id": "lac_222bbb",
                    "amount": "1200.00",
                    "credit_or_debit": "credit"
                  }
                ],
                "status": "draft",
                "accounting_date": "2024-03-31T00:00:00Z",
                "memo": "March rent accrual"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Journal entry created (DRAFT, or POSTED when `status: \"posted\"`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_new789",
                  "sequence_number": "JE-44",
                  "status": "draft",
                  "originated_by": "user",
                  "accounting_date": "2024-03-31T00:00:00Z",
                  "posted_at": null,
                  "memo": "March rent accrual",
                  "legal_entity_id": "le_456def",
                  "transaction_id": null,
                  "classification": null,
                  "tag_ids": ["tag_456ghi"],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_ccc333",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "1200.00",
                      "currency": "USD",
                      "memo": "March office rent",
                      "tag_ids": ["tag_456ghi"]
                    },
                    {
                      "id": "jel_ddd444",
                      "ledger_account_id": "lac_222bbb",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "1200.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/post": {
      "post": {
        "operationId": "bulkPostJournalEntries",
        "summary": "Bulk post journal entries",
        "description": "Posts up to 50 journal entries in one call, advancing each from `DRAFT` or `UNPOSTED` to `POSTED` and applying its balance changes to the affected ledger accounts.\n\n**MUTATING** — once posted, an entry's balance contribution is live in the general ledger.\n\n### Unwind path\n\nA posted entry can only be undone by one of:\n\n- **Unpost** (`POST /v1/journal-entries/{id}/unpost`) — roll back to `UNPOSTED`.\n- **Reverse** (`POST /v1/journal-entries/reverse`) — mirror cancellation.\n- **Reclass** (`POST /v1/journal-entries/bulk-reclass`) — move to a different account.\n\nTo remove `DRAFT` / `ERROR` entries entirely without ever touching the ledger, use `POST /v1/journal-entries/delete` instead.\n\n### Partial-success semantics\n\nEach entry is independently validated — some can succeed while others fail. The response surfaces both sets.\n\nPass `idempotencyKey` to make the call replay-safe. To undo posting in bulk, use `tools.journal_entries.bulk_unpost_journal_entries`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed journal entry IDs (`je_`)."
                  }
                }
              },
              "example": {
                "ids": ["je_abc123", "je_def456"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["je_abc123"],
                    "failed": [
                      {
                        "id": "je_def456",
                        "reason": "Journal entry status is posted — only draft or voided entries can be posted"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/reverse": {
      "post": {
        "operationId": "bulkReverseJournalEntries",
        "summary": "Bulk reverse journal entries",
        "description": "Reverses up to 50 `POSTED` journal entries by creating an opposite mirror entry for each, leaving the originals intact for audit traceability.\n\n**DESTRUCTIVE** — adds new ledger movement that offsets the original. Both the original entry and its reversal remain visible in audit.\n\n### Pre-conditions\n\n- Every input entry must be `POSTED`.\n- Cannot itself already be a reversal — you cannot reverse a reversal.\n\n### Choose the right operation\n\n- **Unpost** (`POST /v1/journal-entries/{id}/unpost`) — roll entries back to `UNPOSTED` without creating new ledger lines.\n- **Delete** (`POST /v1/journal-entries/delete`) — DRAFT / ERROR cleanup.\n- **Reclass** (`POST /v1/journal-entries/bulk-reclass`) — move entries to a different ledger account rather than canceling them.\n- **Post** (`POST /v1/journal-entries/post`) — promote DRAFT entries to the ledger.\n\nUses partial-success semantics. Pass `idempotencyKey` to make the call replay-safe.\n\n### Safe operation workflow\n\n1. Call once with `dry_run: true` to preview the exact set — the response returns `{ dry_run, summary: { requested, eligible, failed }, eligible: [...], failed: [...] }` and changes nothing.\n2. Re-call without `dry_run`, setting `confirm_count` to the number of distinct ids you intend to affect; the request is rejected (400) if it does not equal the distinct id count. To detach and roll back instead of mirroring, use `tools.journal_entries.bulk_unpost_journal_entries`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed journal entry IDs (`je_`)."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the number of distinct ids in the request, otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns the eligible/failed preview without mutating anything. Use to preview the exact scope before committing."
                  }
                }
              },
              "example": {
                "ids": ["je_abc123"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["je_abc123"],
                    "failed": []
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/delete": {
      "post": {
        "operationId": "bulkDeleteJournalEntries",
        "summary": "Bulk delete journal entries",
        "description": "Soft-deletes up to 50 journal entries that have NOT been posted to the ledger.\n\n**DESTRUCTIVE** — removes the entry record from list / get results. Recovery requires operator intervention.\n\n### Pre-conditions\n\n- Every input entry must be in `DRAFT`, `ERROR`, or `UNPOSTED` (voided) status.\n- Synced entries are rejected even at voided status.\n\nTo delete a `POSTED` entry, first unpost it via `POST /v1/journal-entries/{id}/unpost`, then delete the unposted record.\n\n### Choose the right operation\n\n- **Reverse** (`POST /v1/journal-entries/reverse`) — when audit policy requires the original entry to stay visible.\n- **Reclass** (`POST /v1/journal-entries/bulk-reclass`) — move entries to a different account.\n- **Post** (`POST /v1/journal-entries/post`) — promote DRAFT entries to the ledger.\n\nUses partial-success semantics — each entry is independently validated. Pass `idempotencyKey` to make the call replay-safe.\n\n### Safe deletion workflow\n\n1. Call once with `dry_run: true` to preview the exact set. The response returns `{ dry_run, summary: { requested, eligible, failed }, eligible: [...], failed: [...] }` and deletes nothing.\n2. Re-call without `dry_run`, setting `confirm_count` to the number of distinct ids you intend to delete. The request is rejected (400) if `confirm_count` does not match the distinct id count, so an over-broad list cannot silently delete more than intended. To roll back POSTED entries instead of deleting drafts, use `tools.journal_entries.bulk_unpost_journal_entries`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Array of prefixed journal entry IDs (`je_`)."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the number of distinct ids in `ids`, otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns the eligible/failed breakdown without deleting anything. Use to preview scope before committing."
                  }
                }
              },
              "example": {
                "ids": ["je_abc123", "je_def456"],
                "confirm_count": 2
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "succeeded": ["je_abc123"],
                    "failed": [
                      {
                        "id": "je_def456",
                        "reason": "Journal entry status is posted — only draft, error, or voided entries can be deleted"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/post": {
      "post": {
        "operationId": "postJournalEntry",
        "summary": "Post a journal entry",
        "description": "Posts a single journal entry, advancing it from `DRAFT` or `UNPOSTED` to `POSTED` and applying its balance contribution to the affected ledger accounts.\n\n**MUTATING** — the entry's balance impact becomes live in the general ledger. Further changes require one of the unwind operations below.\n\n### Pre-conditions\n\n- Entry status must be `DRAFT` or `UNPOSTED`.\n- The accounting period must be open.\n\n### Unwind path\n\n- **Unpost** (`POST /v1/journal-entries/{id}/unpost`) — roll back to `UNPOSTED`; preserves the record.\n- **Reverse** (`POST /v1/journal-entries/{id}/reverse`) — cancel via a mirror entry; preserves the audit trail.\n- **Reclass** (`POST /v1/journal-entries/bulk-reclass`) — move to a different ledger account.\n- **Delete** (`DELETE /v1/journal-entries/{id}`) — only after unposting.\n\n### Report only the state you read back\n\nA 2xx here does not entitle you to describe the entry as booked. Re-read it — `tools.journal_entries.get_journal_entry_by_sequence` for a `JE-` number, `tools.journal_entries.get_journal_entry` for a `je_` id — and report the `status` you actually observed. Never infer a posting outcome from an earlier list call, and never report an entry as posted for a transaction you have just listed as unaccounted: `has_posted_journal_entry=false` on `tools.transactions.list_transactions` filters POSTED coverage only, so such a transaction can still own a `DRAFT` or `ERROR` entry. A transaction cannot be both unaccounted and successfully posted — if your answer asserts both, one half is wrong, so re-read before replying.\n\n### `POSTED` does not mean it reached the GL\n\n`POSTED` is Entendre-side only. Delivery to QuickBooks / Xero / NetSuite is a separate outcome: read `is_sync` and `latest_gl_sync_attempt` on the entry, or filter `tools.journal_entries.list_journal_entries` by `sync_status` (`synced` / `pending` / `failed`) and `sync_error_types`. When you report a batch you just posted, give both counts — how many posted, and how many actually synced — because the two routinely differ. A frequent outcome is `POSTED` plus `sync_status=failed` with `DUPLICATE_DETECTED`, meaning the provider already holds an equivalent native record (a QuickBooks `Purchase`, say) for the same date, amount and account; `latest_gl_sync_attempt.error_details` names the matched record. Reporting that batch as fully booked hides a double-count.\n\n### Related\n\n- For metadata edits (memo, `accounting_date`, `tag_ids`) on a `DRAFT` entry, use `PATCH /v1/journal-entries/{id}` instead.\n- For posting multiple entries at once, use `POST /v1/journal-entries/post`.\n\nPass `idempotencyKey` to make the call replay-safe. To undo a POSTED entry, use `tools.journal_entries.unpost_journal_entry`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Journal entry posted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_abc123",
                  "sequence_number": "JE-42",
                  "status": "posted",
                  "originated_by": "api",
                  "accounting_date": "2024-03-09T00:00:00Z",
                  "posted_at": "2024-03-10T15:30:00Z",
                  "memo": "ETH deposit",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "template_id": null,
                  "classification": "DEPOSIT",
                  "tag_ids": [],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_aaa111",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_bbb222",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-10T15:30:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/reverse": {
      "post": {
        "operationId": "reverseJournalEntry",
        "summary": "Reverse a journal entry",
        "description": "Reverses a single `POSTED` journal entry by creating an opposite mirror entry, leaving the original visible for audit.\n\n**DESTRUCTIVE** — adds new ledger movement that offsets the original's balance impact. The original transitions to `REVERSED` and the new entry's `reversalChain.previousEntryId` points back.\n\n### Pre-conditions\n\n- Entry must be `POSTED`.\n- Cannot itself already be a reversal — you cannot reverse a reversal.\n\n### Choose the right operation\n\n- **Unpost** (`POST /v1/journal-entries/{id}/unpost`) — unpost without creating any new ledger lines.\n- **Delete** (`DELETE /v1/journal-entries/{id}`) — only on `DRAFT` / `UNPOSTED` entries.\n- **Update** (`PATCH /v1/journal-entries/{id}`) — memo / date / tag edits on a `DRAFT`.\n- **Batch** (`POST /v1/journal-entries/reverse`) — up to 50 in one call.\n- **Post** (`POST /v1/journal-entries/{id}/post`) — promote a DRAFT to the ledger first.\n\nPass `idempotencyKey` to make the call replay-safe. To detach and roll back instead of creating a mirror entry, use `tools.journal_entries.unpost_journal_entry`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "201": {
            "description": "Reversal journal entry created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_xyz789",
                  "sequence_number": "JE-44",
                  "status": "posted",
                  "originated_by": "api",
                  "accounting_date": "2024-03-31T00:00:00Z",
                  "posted_at": "2024-03-15T09:00:00Z",
                  "memo": "Reversal of JE-42",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "classification": "DEPOSIT",
                  "tag_ids": [],
                  "reversal_chain": {
                    "previous_entry_id": "je_abc123",
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_ccc333",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_ddd444",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-15T09:00:00Z",
                  "updated_at": "2024-03-15T09:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/unsync": {
      "post": {
        "operationId": "unsyncJournalEntry",
        "summary": "Unsync a journal entry",
        "description": "Voids the external general ledger record (QuickBooks, Xero, NetSuite, or DualEntry) for a single journal entry while leaving the entry `POSTED` in Entendre with balances intact. DESTRUCTIVE on the external GL side — writes a void to the partner system; only the external record is mutated, the Entendre entry record is unchanged. Use when the GL sync was incorrect but the underlying entry is correct (resyncing later will repush). For batch unsyncing of multiple specific entries, use `tools.gl.unsync_journal_entries` (max 100 IDs). To unsync every synced JE in a whole period, use `tools.gl.unsync_accounting_period` instead — that variant is the most destructive of the three. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Journal entry unsynced from external GL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_abc123",
                  "sequence_number": "JE-42",
                  "status": "posted",
                  "originated_by": "api",
                  "accounting_date": "2024-03-09T00:00:00Z",
                  "posted_at": "2024-03-10T15:30:00Z",
                  "memo": "ETH deposit",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "template_id": null,
                  "classification": "DEPOSIT",
                  "tag_ids": [],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_aaa111",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_bbb222",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-11T08:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/unpost": {
      "post": {
        "operationId": "unpostJournalEntry",
        "summary": "Unpost a journal entry",
        "description": "Unposts a POSTED journal entry back to UNPOSTED and DETACHES it from its transaction(s) — clearing the journal-entry link and setting hasAccounting=false so the transaction returns to unaccounted — while reversing balance deltas and cleaning up the rule-set match and asset assignments. This matches how the dashboard and copilot 'unpost' (they detach). If the entry is synced to an external GL, the synced ERP-side copy is removed first — the provider is derived from the entry's sync record, never from caller input. Use `tools.journal_entries.unsync_journal_entry` to remove only the external GL record while keeping the entry POSTED. Returns 409 if the entry is not POSTED or sits in a closed accounting period. Related single-entry ops: `tools.journal_entries.post_journal_entry`, `tools.journal_entries.reverse_journal_entry`, `tools.journal_entries.delete_journal_entry`, `tools.journal_entries.update_journal_entry`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Journal entry unposted back to UNPOSTED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_abc123",
                  "sequence_number": "JE-42",
                  "status": "unposted",
                  "originated_by": "api",
                  "accounting_date": "2024-03-09T00:00:00Z",
                  "posted_at": "2024-03-10T15:30:00Z",
                  "memo": "ETH deposit",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "template_id": null,
                  "classification": "DEPOSIT",
                  "tag_ids": [],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_aaa111",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_bbb222",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-11T08:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "The external GL provider rejected or failed the removal of the synced ERP copy (`EXTERNAL_SERVICE_ERROR` / `PROVIDER_ERROR`, e.g. the Xero daily API limit). The entry remains POSTED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/journal-entries/{id}/gl-link": {
      "delete": {
        "operationId": "deleteJournalEntryGlLink",
        "summary": "Remove a journal entry’s GL link",
        "description": "Removes ONLY the link between a journal entry and its external GL record (soft-deletes the sync/import metadata and clears the synced flag) WITHOUT contacting the provider — the ERP-side record and the entry’s POSTED status are both left untouched. Use when the ERP record was already deleted upstream, or to disconnect both sides while keeping them. Contrast: `tools.journal_entries.unsync_journal_entry` also voids the external GL record. Returns 409 if the entry is not POSTED or is not currently linked.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "GL link removed; entry remains POSTED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_abc123",
                  "sequence_number": "JE-42",
                  "status": "posted",
                  "originated_by": "api",
                  "accounting_date": "2024-03-09T00:00:00Z",
                  "posted_at": "2024-03-10T15:30:00Z",
                  "memo": "ETH deposit",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "template_id": null,
                  "classification": "DEPOSIT",
                  "tag_ids": [],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_aaa111",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_bbb222",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-11T08:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/by-sequence/{sequence_number}": {
      "get": {
        "operationId": "getJournalEntryBySequence",
        "summary": "Get a journal entry by sequence number",
        "description": "Retrieves a single journal entry by its customer-facing sequence number (e.g. `JE-2096`) instead of the internal `je_*` prefixed ID.\n\nReturns the same response shape as `GET /v1/journal-entries/{id}`.\n\n### Performance\n\nBacked by the compound index `(organizationId, journalSequenceNumber)`, so this is an O(1) lookup. Do NOT paginate `GET /v1/journal-entries` and match on `sequence_number` client-side for this case.\n\n### Path parameter\n\nThe `sequence_number` path parameter must match `JE-{positive_integer}` (case-insensitive prefix). Shape mismatches return `400`.\n\n### Soft-deleted entries\n\nExcluded from the lookup, matching the canonical `GET /v1/journal-entries/{id}` semantics.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "name": "sequence_number",
            "in": "path",
            "required": true,
            "description": "Customer-facing JE sequence (e.g. `JE-2096`). Case-insensitive on the prefix.",
            "schema": {
              "type": "string",
              "pattern": "^JE-\\d+$",
              "example": "JE-2096"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Journal entry found.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/JournalEntry"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/journal-entries/{id}": {
      "get": {
        "operationId": "getJournalEntry",
        "summary": "Get a journal entry",
        "description": "Retrieves a single journal entry by its prefixed ID, including its populated `lines` array. Returns the same shape as a list response item.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Journal entry found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_abc123",
                  "sequence_number": "JE-42",
                  "status": "posted",
                  "originated_by": "system",
                  "accounting_date": "2024-03-09T00:00:00Z",
                  "posted_at": "2024-03-09T10:30:00Z",
                  "memo": "ETH inflow from client",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "template_id": "tpl_789xyz",
                  "classification": "DEPOSIT",
                  "tag_ids": ["tag_456ghi"],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_def456",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": "ETH inflow",
                      "tag_ids": []
                    },
                    {
                      "id": "jel_ghi789",
                      "ledger_account_id": "lac_222bbb",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": "ETH inflow",
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-09T10:30:00Z",
                  "updated_at": "2024-03-09T10:30:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateJournalEntry",
        "summary": "Update a journal entry",
        "description": "Updates the mutable metadata fields on a single `DRAFT` journal entry without affecting the ledger.\n\n### Mutable fields\n\n- `memo`\n- `accounting_date`\n- `tag_ids`\n\nLine items and amounts cannot be edited via this endpoint.\n\n### Pre-condition\n\nEntry status must be `DRAFT`. `POSTED`, `UNPOSTED`, `REVERSED`, and `ERROR` entries reject the request.\n\n### Correcting a posted entry\n\nThere is no \"edit a posted entry\" path. `POST /v1/journal-entries/{id}/unpost` brings a `POSTED` entry to `UNPOSTED`, but `UNPOSTED` is also rejected by this endpoint. For posted-entry corrections use one of:\n\n- **Reverse** (`POST /v1/journal-entries/{id}/reverse`) — mirror cancellation, audit-safe.\n- **Reclass** (`POST /v1/journal-entries/bulk-reclass`) — move to a different account.\n\n### Related\n\n- `DELETE /v1/journal-entries/{id}` — remove a DRAFT entirely.\n- `POST /v1/journal-entries/{id}/post` — promote the edited DRAFT to the ledger.\n\nPass `idempotencyKey` to make the call replay-safe. To pull a POSTED entry back before editing, use `tools.journal_entries.unpost_journal_entry`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "memo": {
                    "type": "string"
                  },
                  "accounting_date": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Journal entry updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JournalEntry"
                },
                "example": {
                  "id": "je_new456",
                  "sequence_number": "JE-43",
                  "status": "draft",
                  "originated_by": "api",
                  "accounting_date": "2024-03-31T00:00:00Z",
                  "posted_at": null,
                  "memo": "Updated: ETH inflow reclassified",
                  "legal_entity_id": "le_456def",
                  "transaction_id": "txn_8923749823",
                  "classification": "DEPOSIT",
                  "tag_ids": ["tag_456ghi"],
                  "reversal_chain": {
                    "previous_entry_id": null,
                    "next_entry_id": null
                  },
                  "lines": [
                    {
                      "id": "jel_aaa111",
                      "ledger_account_id": "lac_789abc",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "DEBIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    },
                    {
                      "id": "jel_bbb222",
                      "ledger_account_id": "lac_clearing",
                      "legal_entity_id": "le_456def",
                      "credit_or_debit": "CREDIT",
                      "amount": "5250.00",
                      "currency": "USD",
                      "memo": null,
                      "tag_ids": []
                    }
                  ],
                  "created_at": "2024-03-10T14:30:00Z",
                  "updated_at": "2024-03-10T16:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteJournalEntry",
        "summary": "Delete a journal entry",
        "description": "Soft-deletes a single journal entry that has not been posted to the ledger.\n\n**DESTRUCTIVE** — removes the entry from list / get results. Recovery requires operator intervention.\n\n### Pre-conditions\n\n- Entry status must be `DRAFT`, `ERROR`, or `UNPOSTED` (voided).\n- Must not be synced to an external GL.\n\nTo delete a `POSTED` entry, first call `POST /v1/journal-entries/{id}/unpost` to bring it back to `UNPOSTED`, then call this endpoint.\n\n### Choose the right operation\n\n- **Reverse** (`POST /v1/journal-entries/{id}/reverse`) — when audit policy requires the original to stay visible alongside an offset.\n- **Update** (`PATCH /v1/journal-entries/{id}`) — memo / date / tag edits on a `DRAFT` without deleting.\n- **Reclass** (`POST /v1/journal-entries/bulk-reclass`) — move entries to a different account.\n- **Post** (`POST /v1/journal-entries/{id}/post`) — promote a `DRAFT` to `POSTED`.\n- **Batch delete** (`POST /v1/journal-entries/delete`) — up to 50 entries in one call.\n\nPass `idempotencyKey` to make the call replay-safe. To roll back a POSTED entry instead of deleting a draft, use `tools.journal_entries.unpost_journal_entry`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "204": {
            "description": "Journal entry deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/balances": {
      "get": {
        "operationId": "listBalances",
        "summary": "List balances",
        "description": "Lists GL-derived period-end balances per ledger account per accounting period.\n\nReturns system-maintained running totals (opening balance, period activity, closing balance), updated automatically when journal entries are posted. Results are ordered by accounting period, newest first.\n\n### When NOT to use this\n\nDo NOT use this for \"how much cash do we have right now?\":\n\n- Live consolidated answer: `GET /v1/treasury/cash-position`.\n- Per-source feeds: `GET /v1/balances/token` (on-chain wallets) and `GET /v1/balances/exchange` (CCXT exchanges).\n\n### Choosing among the balance & treasury tools\n\n- `tools.treasury.get_treasury_cash_position` — live cash TOTAL + per-source breakdown; the canonical \"how much cash right now\" answer.\n- `tools.balances.get_live_balances` — live per-source rows (wallets + exchanges + banks) grouped; auto-backgrounds for large orgs.\n- `tools.balances.get_token_balances` — on-chain wallet feed only (single source).\n- `tools.balances.get_exchange_balances` — exchange feed only (single source, daily snapshot).\n- `tools.balances.list_balances` — GL-derived period-end BOOK balances, NOT live cash.\n\n### Pagination\n\nFor full enumeration set `limit: 100` (the max enforced by the schema); default 50 is for interactive lookups. Cursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["Balances"],
        "parameters": [
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter to one or more prefixed legal entity IDs (`le_…`), comma-separated. Optional — omit to span all legal entities in the org."
          },
          {
            "name": "ledger_account_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter to one or more prefixed ledger account IDs (`lac_…`), comma-separated."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter to one or more prefixed accounting period IDs (`ap_…`), comma-separated."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of balances.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Balance"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "bal_abc123",
                      "ledger_account_id": "lac_456def",
                      "accounting_period_id": "ap_789ghi",
                      "legal_entity_id": "le_456def",
                      "period_start_date": "2024-03-01T00:00:00Z",
                      "currency": "USD",
                      "opening": "8600.00",
                      "closing": "20400.00",
                      "total_debits": "15000.00",
                      "total_credits": "3200.00",
                      "updated_at": "2024-03-31T23:59:59Z"
                    }
                  ],
                  "has_more": false,
                  "next_cursor": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods": {
      "get": {
        "operationId": "listAccountingPeriods",
        "summary": "List accounting periods",
        "description": "Returns accounting periods in reverse chronological order (newest first) with the full record shape — every status field, close metadata, and timestamps. Periods are created automatically when transactions are imported, so the result set grows monotonically.\n\n### Status reference\n\n- `open` — free posting.\n- `soft_closed` — elevated permissions only.\n- `closed` — permanently locked.\n\n### Duplicate periods per month\n\nSome legacy orgs have multiple `ap_*` records per calendar month (one per legal entity, plus historical archived entries). When picking a period for downstream calls:\n\n- Dedupe client-side by `(legal_entity_id, start_date)`.\n- Prefer the most-recently-updated record.\n\n### Lighter variants\n\n- `GET /v1/accounting-periods/lite` — id + status + dates only. Use for pickers and dropdowns.\n- `GET /v1/accounting-periods/scheduled` — only periods scheduled for auto-close by the close cron.\n\n### Pagination\n\nCursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": " If omitted, returns periods across all legal entities."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["open", "soft_closed", "closed"]
            }
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "required": false,
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Filter by one or more legal entity ids. Repeat the parameter per value (e.g. ?legal_entity_ids=a&legal_entity_ids=b) or pass a comma-separated list."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of accounting periods.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/AccountingPeriod"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "ap_789ghi",
                      "legal_entity_id": "le_456def",
                      "name": "March 2024",
                      "start_date": "2024-03-01T00:00:00Z",
                      "end_date": "2024-03-31T23:59:59Z",
                      "status": "open",
                      "created_at": "2024-03-01T00:00:00Z"
                    }
                  ],
                  "has_more": false,
                  "next_cursor": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/{id}/close-preflight": {
      "get": {
        "operationId": "getAccountingPeriodClosePreflight",
        "summary": "Pre-close audit for an accounting period",
        "description": "Returns the audit payload a caller should review BEFORE invoking `POST /v1/accounting-periods/{id}/close` on this period. Read-only — calling preflight does NOT close the period.\n\nUse this whenever you need to answer \"is it safe to close May 2026?\" or \"what's still unposted for this month?\" — one call instead of composing `/transactions` + `/journal-entries` + filtering manually.\n\n### What the payload covers\n\nPreconditions that would block the close:\n\n- Earlier period still open.\n- No next period exists.\n- Next period is not open.\n- Already closed.\n\nWhat would be stranded if it does close:\n\n- Count + sum of pending transactions in the period that have no journal entry.\n- Count of draft journal entries dated in the period.\n\n### Recommendation field\n\n- `safe_to_close` — preconditions pass AND zero pending transactions AND zero draft JEs in the period.\n- `hold` — at least one blocker; the blocking reasons are enumerated in the response.\n\n### Preview limits\n\nPreview arrays cap at 25 items each. For the full list:\n\n- Pending transactions: paginate `GET /v1/transactions` with `has_accounting=false` + period date bounds.\n- Draft journal entries: paginate `GET /v1/journal-entries` with `?statuses=draft`.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Preflight audit payload.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "accounting_period": {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "string"
                            },
                            "status": {
                              "type": "string",
                              "enum": ["Open", "Closed", "Soft Closed"]
                            },
                            "start_date": {
                              "type": "string",
                              "format": "date"
                            },
                            "end_date": {
                              "type": "string",
                              "format": "date"
                            },
                            "legal_entity_id": {
                              "type": "string"
                            }
                          }
                        },
                        "preconditions": {
                          "type": "object",
                          "properties": {
                            "can_close": {
                              "type": "boolean"
                            },
                            "blocking_reasons": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "pending_transactions": {
                          "type": "object",
                          "properties": {
                            "count": {
                              "type": "integer"
                            },
                            "sum_credit_usd": {
                              "type": "number"
                            },
                            "sum_debit_usd": {
                              "type": "number"
                            },
                            "preview": {
                              "type": "array"
                            }
                          }
                        },
                        "draft_journal_entries": {
                          "type": "object",
                          "properties": {
                            "count": {
                              "type": "integer"
                            },
                            "preview": {
                              "type": "array"
                            }
                          }
                        },
                        "recommendation": {
                          "type": "string",
                          "enum": ["safe_to_close", "hold"]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/accounting-periods/{id}/close": {
      "post": {
        "operationId": "closeAccountingPeriod",
        "summary": "Close an accounting period",
        "description": "Closes the accounting period — a near-irreversible action.\n\n- Records closing balances.\n- Locks all `POSTED` journal entries against further edits.\n- Passes the closing balances forward to the next open period.\n\nOnce closed, posting into the period requires admin reopen and may invalidate downstream period balances.\n\n### Run preflight first\n\nAlways call `GET /v1/accounting-periods/{id}/close-preflight` **before** this endpoint to see what would be stranded (unposted transactions, draft JEs) and confirm preconditions (earlier periods closed, next period open).\n\nIf preflight returns `recommendation: 'hold'`, do not call this — post the stranded items first, then re-run preflight.\n\n### Fails when\n\n- Earlier periods are still open.\n- No next period exists.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Accounting period closed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AccountingPeriod"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "ap_789ghi",
                    "legal_entity_id": "le_456def",
                    "name": "March 2024",
                    "start_date": "2024-03-01T00:00:00Z",
                    "end_date": "2024-03-31T23:59:59Z",
                    "status": "closed",
                    "closed_on_date": "2024-05-01T12:00:00Z",
                    "created_at": "2024-03-01T00:00:00Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "description": "Period cannot be closed yet (too early).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/{id}/reopen": {
      "post": {
        "operationId": "reopenAccountingPeriod",
        "summary": "Reopen an accounting period",
        "description": "Reopens a previously `closed` or `soft_closed` accounting period, returning it to `open` status so journal entries can be posted into the period again. DESTRUCTIVE — reopening removes the close lock and allows the period's balances to change; downstream reports rendered against the closed snapshot will diverge. Requires elevated permissions. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Accounting period reopened.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccountingPeriod"
                },
                "example": {
                  "id": "ap_789ghi",
                  "legal_entity_id": "le_456def",
                  "name": "March 2024",
                  "start_date": "2024-03-01T00:00:00Z",
                  "end_date": "2024-03-31T23:59:59Z",
                  "status": "open",
                  "created_at": "2024-03-01T00:00:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/{id}": {
      "get": {
        "operationId": "getAccountingPeriod",
        "summary": "Get an accounting period",
        "description": "Returns a single accounting period by its prefixed `ap_` ID — start / end dates, legal entity, close status, and revaluation state. Use this for one-off lookups when the period ID is known; for browsing use `tools.accounting_periods.list_accounting_periods` instead. Read-only.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Accounting period details.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AccountingPeriod"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "ap_789ghi",
                    "legal_entity_id": "le_456def",
                    "name": "March 2024",
                    "start_date": "2024-03-01T00:00:00Z",
                    "end_date": "2024-03-31T23:59:59Z",
                    "status": "open",
                    "created_at": "2024-03-01T00:00:00Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/table-groups": {
      "get": {
        "operationId": "listTableGroups",
        "summary": "List table groups",
        "description": "Lists the organization's table groups — saved groupings of asset or source rows — ordered by name (ascending). Filter with `table_type` to get only asset-organizing or source-organizing groups. Use `tools.table_groups.get_table_group` for one group by id, `tools.table_groups.list_table_group_members` for a group's member sources, and `tools.table_groups.get_table_groups_feed` for the grouped source feed. Keyset-paginated: `limit` defaults to 50 (max 100); pass `cursor` for the next page.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 },
            "description": "Max rows to return (default 50, max 100)."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": { "type": "string", "maxLength": 4096 },
            "description": "Keyset cursor from a previous page's `next_cursor`."
          },
          {
            "name": "table_type",
            "in": "query",
            "schema": { "type": "string", "enum": ["asset", "source"] },
            "description": "Filter to groups that organize `asset` rows or `source` rows."
          }
        ],
        "responses": {
          "200": {
            "description": "Keyset-paginated list of table groups.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/V1TableGroup" } },
                    "has_more": { "type": "boolean" },
                    "next_cursor": { "type": ["string", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "operationId": "createTableGroup",
        "summary": "Create a table group",
        "description": "Creates a table group — a saved folder that organizes either asset rows or source rows (wallets, exchange sources, bank/card accounts). `table_type` is fixed at creation and cannot be changed later; `PATCH /v1/table-groups/{id}` only edits `name` / `icon` / `emoji`. Optionally attach members in the same call.\n\n### Member validation is all-or-nothing\n\nEvery id in `members` must belong to your organization and match its declared `type`. If ANY id is foreign or unknown the whole request returns `400` and no group is created — a partial payload can never silently attach only the recognized subset. Attach members later with `POST /v1/table-groups/{id}/members`.\n\n### Member types\n\n`wallet` (`wal_`), `raincard` (`rnc_`), `niural` (`nis_`), `plaid_account` (`pla_`), `exchange_source` (`exs_`), `ramp_card` (`crd_`), `ramp_bank_account` (`rba_`). `manual_bank_account` appears in the grouped feed but is read-only and cannot be a member.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "table_type"],
                "additionalProperties": false,
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256
                  },
                  "table_type": {
                    "type": "string",
                    "enum": ["asset", "source"],
                    "description": "Whether the folder organizes asset rows or source rows. Immutable after creation."
                  },
                  "icon": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "emoji": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "reference_wallet_id": {
                    "type": "string",
                    "description": "Prefixed `wal_` id of a wallet in your organization to associate with the folder."
                  },
                  "members": {
                    "type": "array",
                    "maxItems": 1000,
                    "description": "Optional members to attach at creation (max 1000). All-or-nothing: one unowned id rejects the whole request.",
                    "items": {
                      "$ref": "#/components/schemas/V1TableGroupMemberRef"
                    }
                  }
                }
              },
              "example": {
                "name": "Treasury wallets",
                "table_type": "source",
                "emoji": "🏦",
                "members": [
                  {
                    "type": "wallet",
                    "id": "wal_64f1a2b3c4d5e6f708192a3b"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created table group.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["table_group", "members_attached"],
                      "properties": {
                        "table_group": {
                          "$ref": "#/components/schemas/V1TableGroup"
                        },
                        "members_attached": {
                          "type": "integer",
                          "description": "Members attached by this call (0 when `members` was omitted)."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/table-groups/feed": {
      "get": {
        "operationId": "getTableGroupsFeed",
        "summary": "Get the table-groups feed",
        "description": "Returns the source feed organized by table group. `view=grouped` (default) yields folder rows — each a table group with its member sources nested in `sub_rows` — alongside ungrouped sources; `view=flat` returns the UNGROUPED sources only (no folders). Narrow with `search`, `table_group_id`, the `include_ungrouped`/`ungrouped_only`/`grouped_only` toggles, the `sort_by`/`sort_direction` knobs, and the multi-value `legal_entity_ids`/`chains`/`wallet_types`/`statuses`/`source_types`/`tag_ids` filters. Use `tools.table_groups.list_table_groups` for just the groups without their members. Keyset-paginated: `limit` defaults to 50 (max 100); pass `cursor` for the next page.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 },
            "description": "Max rows to return (default 50, max 100)."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": { "type": "string", "maxLength": 4096 },
            "description": "Keyset cursor from a previous page's `next_cursor`."
          },
          {
            "name": "view",
            "in": "query",
            "schema": { "type": "string", "enum": ["grouped", "flat"] },
            "description": "`grouped` (default) nests sources under their group; `flat` returns a flat source list."
          },
          {
            "name": "search",
            "in": "query",
            "schema": { "type": "string", "maxLength": 256 },
            "description": "Case-insensitive substring match on group / source name."
          },
          {
            "name": "table_group_id",
            "in": "query",
            "schema": { "type": "string" },
            "description": "Prefixed `tbg_` id — restrict the feed to one group."
          },
          {
            "name": "include_ungrouped",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Include sources not assigned to any group."
          },
          {
            "name": "ungrouped_only",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Return only sources not assigned to any group."
          },
          {
            "name": "grouped_only",
            "in": "query",
            "schema": { "type": "boolean" },
            "description": "Return only sources assigned to a group."
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": { "type": "string", "enum": ["name", "createdAt"] },
            "description": "Sort key."
          },
          {
            "name": "sort_direction",
            "in": "query",
            "schema": { "type": "string", "enum": ["asc", "desc"] },
            "description": "Sort direction."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Prefixed `le_` ids (comma-separated or repeated) — filter to these legal entities."
          },
          {
            "name": "chains",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Chain identifiers (comma-separated or repeated)."
          },
          {
            "name": "wallet_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Wallet types (comma-separated or repeated)."
          },
          {
            "name": "statuses",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Source statuses (comma-separated or repeated)."
          },
          {
            "name": "source_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Source types (comma-separated or repeated)."
          },
          {
            "name": "tag_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "description": "Prefixed `tag_` ids (comma-separated or repeated)."
          }
        ],
        "responses": {
          "200": {
            "description": "Keyset-paginated feed of table-group folders and/or sources.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "oneOf": [
                          { "$ref": "#/components/schemas/V1TableGroupFeedFolder" },
                          { "$ref": "#/components/schemas/V1TableGroupFeedItem" }
                        ]
                      }
                    },
                    "has_more": { "type": "boolean" },
                    "next_cursor": { "type": ["string", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/table-groups/{id}": {
      "get": {
        "operationId": "getTableGroup",
        "summary": "Get a table group",
        "description": "Returns a single table group by its `tbg_` id, including its type, name, icon/emoji, and reference wallet. Responds with an ETag for conditional requests. Use `tools.table_groups.list_table_group_members` to list the sources that belong to it, or `tools.table_groups.list_table_groups` to enumerate all groups.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `tbg_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The table group.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "$ref": "#/components/schemas/V1TableGroup" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "patch": {
        "operationId": "updateTableGroup",
        "summary": "Rename or restyle a table group",
        "description": "Updates a table group's `name`, `icon`, and/or `emoji`. Metadata only — this does NOT change membership (use the `/members` endpoints) and cannot change `table_type`, which is fixed at creation.\n\n### If-Match\n\nWhen the group has an `updatedAt` timestamp — every group created or edited under v1 does — `If-Match` set to the `ETag` from `GET /v1/table-groups/{id}` is required, and a missing or stale value returns `412`. Legacy folders predating timestamps emit no ETag and skip the check; the first successful PATCH stamps one, so subsequent edits are guarded.\n\nAt least one of `name`, `icon`, `emoji` must be present.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `tbg_` id."
          },
          {
            "name": "If-Match",
            "in": "header",
            "schema": {
              "type": "string"
            },
            "description": "ETag from `GET /v1/table-groups/{id}`. Required for any group that has an `updated_at` (all v1-managed groups); absent or stale returns 412."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "additionalProperties": false,
                "description": "At least one field must be present.",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256
                  },
                  "icon": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "emoji": {
                    "type": "string",
                    "maxLength": 64
                  }
                }
              },
              "example": {
                "name": "Treasury wallets (EU)"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated table group.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1TableGroup"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "412": {
            "description": "If-Match precondition failed — absent or stale. Re-read with `GET /v1/table-groups/{id}` and retry."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteTableGroup",
        "summary": "Delete a table group (the folder only — members are kept)",
        "description": "Soft-deletes the folder and detaches every member from it. **The member records themselves are NOT deleted** — wallets, exchange sources, and bank/card accounts survive untouched and simply become ungrouped, reappearing in the feed's ungrouped section (`tools.table_groups.get_table_groups_feed` with `include_ungrouped=true`). There is no undelete endpoint: recovering the grouping means creating a new folder and re-adding the members. To empty a folder without deleting it, use `PUT /v1/table-groups/{id}/members` with `members: []`.\n\n### Posting-rule guard\n\nReturns `409` when any account posting rule targets this group, listing every blocker in `blocking_rule_ids` and `blocking_rule_names`. Reassign or delete those rules first — deleting a group a rule depends on would break automated journal-entry generation. The soft-delete and the member detach run in one transaction, so a 409 leaves everything untouched.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `tbg_` id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The folder was soft-deleted and its members detached.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "deleted": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "One or more account posting rules are assigned to this group. The response lists `blocking_rule_ids` and `blocking_rule_names`; reassign or delete them first. Nothing was deleted."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/table-groups/{id}/members": {
      "get": {
        "operationId": "listTableGroupMembers",
        "summary": "List a table group's members",
        "description": "Lists the source rows — wallets, exchange sources, bank/card accounts — that belong to the table group identified by its `tbg_` id. Returns `{ data: [...] }` (no pagination) since a group's membership is bounded. Use `tools.table_groups.get_table_group` for the group's own metadata.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `tbg_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The group's member sources.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/V1TableGroupFeedItem" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "operationId": "addTableGroupMembers",
        "summary": "Add members to a table group",
        "description": "Adds the listed sources to the group, leaving existing members in place — this is an ADD, not a replace. A source belongs to at most one group, so attaching one that currently sits in another folder moves it here. To overwrite the membership set wholesale use `PUT /v1/table-groups/{id}/members` (`tools.table_groups.replace_table_group_members`), which detaches everything not in the payload.\n\n### All-or-nothing validation\n\nEvery id must belong to your organization and match its declared `type`. One foreign or unknown id returns `400` and nothing is attached, so a partial payload can never silently attach only the recognized subset. 1-1000 members per call.\n\n### Member types\n\n`wallet` (`wal_`), `raincard` (`rnc_`), `niural` (`nis_`), `plaid_account` (`pla_`), `exchange_source` (`exs_`), `ramp_card` (`crd_`), `ramp_bank_account` (`rba_`). `manual_bank_account` is feed-only and cannot be a member.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `tbg_` id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["members"],
                "additionalProperties": false,
                "properties": {
                  "members": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 1000,
                    "items": {
                      "$ref": "#/components/schemas/V1TableGroupMemberRef"
                    }
                  }
                }
              },
              "example": {
                "members": [
                  {
                    "type": "exchange_source",
                    "id": "exs_64f1a2b3c4d5e6f708192a3b"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Members attached.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["table_group_id", "members_added"],
                      "properties": {
                        "table_group_id": {
                          "type": "string"
                        },
                        "members_added": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "put": {
        "operationId": "replaceTableGroupMembers",
        "summary": "Replace a table group's entire membership",
        "description": "Replaces the group's whole membership set. **Every current member not present in the payload is detached** — sending `members: []` empties the folder completely. This is the destructive counterpart to `POST /v1/table-groups/{id}/members` (`tools.table_groups.add_table_group_members`), which only adds; read the current membership with `tools.table_groups.list_table_group_members` before calling so you know exactly what will be dropped.\n\nDetached sources are NOT deleted — they simply become ungrouped and reappear in the feed's ungrouped section. The detach and re-attach run in one transaction, so a mid-operation failure cannot leave membership half-rewritten.\n\n### All-or-nothing validation\n\nEvery id must belong to your organization and match its declared `type`; one foreign or unknown id returns `400` and the membership is left exactly as it was. Without that guard a partial payload would detach all current members and re-attach only the recognized subset, silently shrinking the folder. Max 1000 members. Emits an audit record.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `tbg_` id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["members"],
                "additionalProperties": false,
                "properties": {
                  "members": {
                    "type": "array",
                    "maxItems": 1000,
                    "description": "The complete desired membership. An empty array clears the folder.",
                    "items": {
                      "$ref": "#/components/schemas/V1TableGroupMemberRef"
                    }
                  }
                }
              },
              "example": {
                "members": [
                  {
                    "type": "wallet",
                    "id": "wal_64f1a2b3c4d5e6f708192a3b"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Membership replaced.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["table_group_id", "members_set"],
                      "properties": {
                        "table_group_id": {
                          "type": "string"
                        },
                        "members_set": {
                          "type": "integer",
                          "description": "Size of the new membership set (0 when the folder was cleared)."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "removeTableGroupMembers",
        "summary": "Remove listed members from a table group",
        "description": "Removes only the listed sources from THIS group, leaving the rest of the membership intact. The source records are NOT deleted — they become ungrouped and reappear in the feed's ungrouped section. To clear the whole folder use `PUT /v1/table-groups/{id}/members` with `members: []`; to delete the folder itself use `DELETE /v1/table-groups/{id}`.\n\n### Idempotent and group-scoped\n\nIds that are unknown, foreign, or already in a different group are silently skipped rather than erroring, so a source that has moved elsewhere is never detached from its new home. `members_removed` reports the documents actually updated, which may be fewer than the ids you sent. 1-1000 members per call.",
        "tags": ["Table Groups"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `tbg_` id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["members"],
                "additionalProperties": false,
                "properties": {
                  "members": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 1000,
                    "items": {
                      "$ref": "#/components/schemas/V1TableGroupMemberRef"
                    }
                  }
                }
              },
              "example": {
                "members": [
                  {
                    "type": "wallet",
                    "id": "wal_64f1a2b3c4d5e6f708192a3b"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Members detached.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["table_group_id", "members_removed"],
                      "properties": {
                        "table_group_id": {
                          "type": "string"
                        },
                        "members_removed": {
                          "type": "integer",
                          "description": "Documents actually detached — unknown, foreign, and already-moved ids are skipped."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/tags": {
      "get": {
        "operationId": "listTags",
        "summary": "List tags",
        "description": "Returns key-value labels for reporting segmentation in reverse chronological order (newest first).\n\nTag keys come from a closed enum: `Customer`, `Supplier`, `System`, `ID`, `Bank Account`, `Cost Center`, `Class`, `Staff`, `Product`, `Workflow`, `Review Status`, `Custom`.\n\nPagination uses keyset cursors (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdQuery"
          },
          {
            "name": "key",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "Customer",
                "Supplier",
                "System",
                "ID",
                "Bank Account",
                "Cost Center",
                "Class",
                "Staff",
                "Product",
                "Workflow",
                "Review Status",
                "Custom"
              ]
            },
            "description": "Filter by tag key. One of the closed enum values (e.g., `Customer`, `Cost Center`)."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["active", "archived"]
            }
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of tags.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Tag"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "tag_abc123",
                      "key": "Cost Center",
                      "value": "Engineering",
                      "status": "active",
                      "created_at": "2024-03-10T14:30:00Z"
                    },
                    {
                      "id": "tag_def456",
                      "key": "Customer",
                      "value": "Acme Corp",
                      "status": "active",
                      "created_at": "2024-03-08T10:00:00Z"
                    }
                  ],
                  "has_more": true,
                  "next_cursor": "eyJpZCI6InRhZ19kZWY0NTYifQ"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createTag",
        "summary": "Create a tag",
        "description": "Creates a new tag the organization can attach to transactions, journal entries, and reports for segment-level filtering and aggregation.\n\nTags are org-scoped, do not affect ledger balances, and are pure metadata used by report-filter queries and copilot search.\n\n### Required body fields\n\n- `key` — the category. Must be from the closed enum.\n- `value` — the label within that key. For example `Polygon Labs` under `Customer`, or `NEEDS_REVIEW` under `Review Status`.\n\nThe pair `(key, value)` is unique per organization. There is no free-form `key` — picking a value outside the enum returns `400`.\n\n### `key` enum\n\n- `Customer`\n- `Supplier`\n- `System`\n- `ID`\n- `Bank Account`\n- `Cost Center`\n- `Class`\n- `Staff`\n- `Product`\n- `Workflow` — state-machine tags.\n- `Review Status` — audit / approval flags.\n- `Custom` — org-specific dimensions that don't fit the above.\n\nPass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["key", "value"],
                "properties": {
                  "key": {
                    "type": "string",
                    "enum": [
                      "Customer",
                      "Supplier",
                      "System",
                      "ID",
                      "Bank Account",
                      "Cost Center",
                      "Class",
                      "Staff",
                      "Product",
                      "Workflow",
                      "Review Status",
                      "Custom"
                    ],
                    "description": "Tag key. Must be one of the closed enum values — a value outside the enum returns `400`."
                  },
                  "value": {
                    "type": "string",
                    "description": "Tag value (the label itself)."
                  }
                }
              },
              "example": {
                "key": "Cost Center",
                "value": "Engineering"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tag created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                },
                "example": {
                  "id": "tag_abc123",
                  "key": "Cost Center",
                  "value": "Engineering",
                  "status": "active",
                  "created_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/tags/{id}": {
      "get": {
        "operationId": "getTag",
        "summary": "Get a tag",
        "description": "Returns a single tag by its prefixed `tag_` ID — key, value (display label), and current `active` / `archived` status. The response shape matches one row from `tools.tags.list_tags`. Read-only.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Tag found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                },
                "example": {
                  "id": "tag_abc123",
                  "key": "Cost Center",
                  "value": "Engineering",
                  "status": "active",
                  "created_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateTag",
        "summary": "Update a tag",
        "description": "Updates mutable fields on a tag — only the `value` (display label) is mutable; the `key` is immutable after creation because it is referenced by transactions, journal entries, and reports. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "value": {
                    "type": "string"
                  }
                }
              },
              "example": {
                "value": "Platform Engineering"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tag updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                },
                "example": {
                  "id": "tag_abc123",
                  "key": "Cost Center",
                  "value": "Platform Engineering",
                  "status": "active",
                  "created_at": "2024-03-10T14:30:00Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteTag",
        "summary": "Delete a tag",
        "description": "Deletes an unused tag (soft-delete). If the tag is currently in use (`usageCount > 0`) the request is rejected with 409 Conflict to avoid leaving dangling references on journal-entry lines and templates — unassign it everywhere first, or use `tools.tags.archive_tag` to hide it while preserving associations. On success the tag no longer appears in listings.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "204": {
            "description": "Tag deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/tags/{id}/archive": {
      "post": {
        "operationId": "archiveTag",
        "summary": "Archive a tag",
        "description": "Archives a tag by setting its status to `archived` — existing tag associations on journal entries and transactions are preserved, but the tag is hidden from new-tag pickers and reporting filters. Returns the updated tag. To restore an archived tag use `tools.tags.unarchive_tag` instead. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Tag archived.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Tag"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Tag is already archived."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/tags/{id}/unarchive": {
      "post": {
        "operationId": "unarchiveTag",
        "summary": "Unarchive a tag",
        "description": "Restores a tag from `archived` back to `active` status — the tag becomes available again in new-tag pickers and reporting filters. Returns the updated tag. The inverse operation is `tools.tags.archive_tag`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Tag unarchived.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Tag"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Tag is not archived."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/jobs/{id}/status": {
      "get": {
        "operationId": "getJobStatus",
        "summary": "Get job status",
        "description": "Returns the current status of one job (`pending` / `running` / `completed` / `failed` / `cancelled`). Designed for polling; subscribe to the corresponding completion webhook instead of polling when possible. For granular progress (processed-vs-total) use `tools.jobs.get_job_progress`.",
        "tags": ["Jobs"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Job status retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                },
                "examples": {
                  "active": {
                    "summary": "Active job",
                    "value": {
                      "id": "job_987xyz",
                      "status": "active",
                      "progress": 45,
                      "result": null
                    }
                  },
                  "completed": {
                    "summary": "Completed job",
                    "value": {
                      "id": "job_987xyz",
                      "status": "completed",
                      "progress": 100,
                      "result": {
                        "transaction_count": 142
                      }
                    }
                  },
                  "failed": {
                    "summary": "Failed job",
                    "value": {
                      "id": "job_987xyz",
                      "status": "failed",
                      "progress": 0,
                      "result": {
                        "error": "Authentication failed: API credentials were rejected by the exchange."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/settings/cost-basis": {
      "get": {
        "operationId": "getCostBasisSettings",
        "summary": "Get cost basis settings",
        "description": "Returns the current cost-basis `methodology` (fifo / lifo / hifo / acb / wacb) and the `accounting_based` lot-tracking scope (`wallet` per-wallet vs `universal` across all wallets) for the organization. Read-only. To change the configuration use `tools.cost_basis.update_cost_basis_settings`.",
        "tags": ["Cost Basis"],
        "responses": {
          "200": {
            "description": "Current cost basis settings.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CostBasisSettings"
                },
                "example": {
                  "methodology": "fifo",
                  "accounting_based": "wallet"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "put": {
        "operationId": "updateCostBasisSettings",
        "summary": "Update cost basis settings",
        "description": "Updates the cost-basis `methodology` and/or `accounting_based` lot-tracking scope for the organization. Provide at least one field. Changes apply to FUTURE calculations only — historical gain / loss records are not retroactively recomputed (DESTRUCTIVE forward-only switch). Per IRS Rev. Proc. 2024-28, US taxpayers must use per-wallet tracking starting January 1, 2025. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Cost Basis"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "methodology": {
                    "type": "string",
                    "enum": ["fifo", "lifo", "hifo", "acb", "wacb"],
                    "description": "Cost basis disposal method. Accepted values: `fifo`, `lifo`, `hifo`, `acb`, `wacb` (case-insensitive on input) — no other value is valid. Optional — omit to leave unchanged; provide at least one of `methodology` or `accounting_based`."
                  },
                  "accounting_based": {
                    "type": "string",
                    "enum": ["wallet", "universal"],
                    "description": "Lot tracking scope. `accounting_based` is the field name, not a value — its only accepted values are `wallet` and `universal`. `wallet` = separate lot queues per wallet (IRS required for US taxpayers from 2025); `universal` = single queue across all wallets. Case-insensitive on input. Optional — omit to leave unchanged; provide at least one of `methodology` or `accounting_based`."
                  }
                }
              },
              "example": {
                "methodology": "hifo",
                "accounting_based": "wallet"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Settings updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CostBasisSettings"
                },
                "example": {
                  "methodology": "hifo",
                  "accounting_based": "wallet"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/realized-gains": {
      "get": {
        "operationId": "getRealizedGainsReport",
        "summary": "Get realized gains report",
        "description": "Returns sold asset lots with their disposal events for an accounting period, scoped to the calling organization. Each item is one asset lot rooted on `date_received`, with its disposals nested as `disposals[]`.\n\nResponse shape is identical to `GET /v1/reports/schedule-of-dispositions` (same underlying query). This endpoint exists so customers can answer \"show me realized gains for <period>\" against the resource they asked for, instead of routing through the schedule-of-dispositions alias.\n\n### Filtering\n\n- `?accounting_period_id=` — single period.\n- `?accounting_period_ids[]=` — multiple periods.\n- When omitted, NO period filter is applied — the report covers ALL periods (org lifetime). Pass `accounting_period_id`(s) to scope to a period.\n\n### Server-side summary\n\nPass `?summary=true` to additionally receive a top-level `summary` object computed server-side over the SAME filters as the page (see `RealizedGainsSummary`): per-currency `by_currency[]` blocks plus flat `total_proceeds`, `total_cost_basis`, `total_gain`, `short_term_gain` / `long_term_gain` (365.25-day holding boundary) when the scope is single-currency. To answer \"what were total realized gains for <period>\" call ONCE with `summary=true&limit=1` and read `summary.total_gain` — do NOT walk every page summing lots client-side. The summary is one aggregation over the same rows the pages return, so it always equals the paginated sum. **Multi-currency scope**: when `summary.by_currency` has more than one entry the flat totals are absent — report each currency's block separately and NEVER add amounts across currencies; state each figure's currency.\n\n### CSV export\n\nUse `GET /v1/reports/realized-gains/export`.\n\n### Known limitation\n\nA per-disposal `realized_return` (cost-basis-method-aware proceeds-minus-basis calculation) is **NOT** computed yet, and the `summary` totals use the same documented per-unit approximation (`sale_price * quantity_sold - cost_basis * quantity_sold`) — NOT methodology-aware (FIFO/LIFO/HIFO/WAC) figures. For a per-disposal estimate derive `cost_basis * quantity_sold` vs `sale_price * quantity_sold` client-side. A future API enhancement will surface methodology-correct values directly.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed accounting period ID (`ap_*`) to scope the report. Combine with `accounting_period_ids[]` for multi-period queries. When omitted, NO period filter is applied — the report covers all periods (org lifetime)."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Additional `ap_*` IDs to include in the scope. Repeats as `?accounting_period_ids=ap_a&accounting_period_ids=ap_b`.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_*`) to scope the report. Omit to include every legal entity in the org.",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters (e.g. `ETH`, `USDC`). Omit to include every asset type.",
            "style": "form",
            "explode": true
          },
          {
            "name": "summary",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, adds a top-level `summary` object — a single server-side aggregation over the same filters as the page. Use `summary=true&limit=1` to answer period-total questions in one call instead of paginating."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated realized-gains report — one item per sold asset lot with disposal events nested.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "asset_type",
                          "asset_record",
                          "date_received",
                          "quantity",
                          "remaining_quantity",
                          "cost_basis",
                          "currency",
                          "disposals"
                        ],
                        "properties": {
                          "asset_type": {
                            "type": "string",
                            "description": "Token / currency symbol (e.g. `ETH`, `USDC`)."
                          },
                          "asset_record": {
                            "type": "string",
                            "description": "Customer-facing asset record identifier."
                          },
                          "date_received": {
                            "type": "string",
                            "format": "date-time",
                            "description": "ISO timestamp when the lot was acquired. Field name matches `tools.reports.get_schedule_of_dispositions` for cross-endpoint consistency."
                          },
                          "quantity": {
                            "type": "string",
                            "description": "Original quantity acquired, as a decimal string."
                          },
                          "remaining_quantity": {
                            "type": "string",
                            "description": "Quantity still held after all disposals."
                          },
                          "cost_basis": {
                            "type": "string",
                            "description": "Cost basis per unit, as a decimal string."
                          },
                          "currency": {
                            "type": "string",
                            "description": "The lot's own currency (its legal entity's currency). Group by this and join to `summary.by_currency`; never sum amounts across currencies."
                          },
                          "disposals": {
                            "type": "array",
                            "description": "Disposal events for this lot, ordered by the underlying query.",
                            "items": {
                              "type": "object",
                              "required": ["sale_date", "quantity_sold", "sale_price"],
                              "properties": {
                                "sale_date": {
                                  "type": "string",
                                  "format": "date-time"
                                },
                                "quantity_sold": {
                                  "type": "string",
                                  "description": "Quantity disposed on this event."
                                },
                                "sale_price": {
                                  "type": "string",
                                  "description": "Proceeds per unit, as a decimal string."
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    },
                    "summary": {
                      "$ref": "#/components/schemas/RealizedGainsSummary"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/operational-rules": {
      "get": {
        "operationId": "listOperationalRules",
        "summary": "List Copilot-memory rules",
        "description": "Returns the organization's complete set of copilot memory rules — classification rules and cash-application rules — newest first. These are the SAME rules the in-app copilot reads and writes: a rule saved via `tools.operational_rules.create_operational_rule` appears here and fires on the copilot's next classification or cash-application run. This endpoint returns a single complete page (`has_more` is always `false` and `next_cursor` always `null`) and accepts only the optional `domain` filter; unsupported query parameters return 400 rather than being ignored. `counts_by_domain` reports the per-domain totals and is returned only when no `domain` filter is applied.\n\nWhen presenting a rule config: the matcher (with match_operator / normalized_matcher) is the AUTHORITATIVE value the engine enforces: show it VERBATIM. A rule title and statement are display/audit text only and often describe a NARROWER scope than the matcher actually enforces; never derive, paraphrase, or invent a matcher from them. Matching is case- and punctuation-insensitive via normalized_matcher: a rule with match_operator=contains matches ANY invoice line whose normalized label contains the matcher, regardless of SKU or product. If a rule has NO matcher, it predates matcher support and compiles/matches nothing: report it as dead (it must be re-saved with an explicit matcher); do not fabricate one.",
        "tags": ["Operational Rules"],
        "x-mcp-complete-collection": true,
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["classification", "cash_application"]
            },
            "description": "Optional domain filter. Omit to return rules from both domains."
          }
        ],
        "responses": {
          "200": {
            "description": "Single complete page of operational rules (this endpoint does not paginate).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OperationalRule"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Always `null` — the returned set is complete."
                    },
                    "counts_by_domain": {
                      "type": "object",
                      "properties": {
                        "classification": {
                          "type": "integer"
                        },
                        "cash_application": {
                          "type": "integer"
                        }
                      },
                      "description": "Total rules per domain. Present ONLY on an unfiltered listing: when `domain` is set the other family's store is never read, so a count for it would be a not-queried zero rather than a real total — the field is omitted instead."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createOperationalRule",
        "summary": "Create or update a Copilot-memory rule",
        "description": "Creates or updates a copilot memory rule. This is an IDEMPOTENT UPSERT — safe to call every time the user gives a categorization directive (“deposits from X should always go to Y”); the response `outcome` is `created` (new rule), `already_exists` (an equivalent rule already covers this — no duplicate written; tell the user the rule already exists), or `replaced` (an equivalent matcher now routes to the new target; tell the user the rule was updated). The rule persists to the SAME org-memory store the in-app copilot uses and fires in its deterministic rule pass on the next classification (or cash-application) run.\n\nFor `domain: classification`, provide `category_ledger_account_id` (resolve it via `tools.ledger_accounts.list_ledger_accounts` first — never guess account ids) plus at least one match criterion, chosen by what actually identifies the transactions: `source_category` for a CLASS of transactions the source platform already labels (Plaid personal-finance category, Ramp accounting category); `merchant_name` for card spend at a specific merchant (Ramp/Raincard); `blockchain_address` (optionally with `asset_type`) for crypto flows — e.g. “deposits from wallet 0xabc → Staking Income” is exactly `{domain: \"classification\", blockchain_address: \"0xabc\", direction: \"DEPOSIT\", category_ledger_account_id: \"…\"}`; and `description_contains` ONLY for literal memo/payee text the user quoted — it is a plain substring match with no semantic understanding (“Bar Harbor Inn” matches those words, not the business type), so prefer the structured fields when the source provides them. `direction` defaults to `ANY`.\n\nFor `domain: cash_application`, provide `title`, `statement`, and the verbatim `matcher` (the exact SKU / product text to match against invoice lines — it is not inferred from the title), plus `ledger_account_id` and `ledger_account_name` for `route_to_account` rules.\n\nThis persists a STANDING rule; to categorize a specific set of transactions right now instead, use `tools.classifications.classify_transactions` (rules created here are inputs to that same engine). This IS the posting-rule surface an agent should use: the legacy template / condition-tree posting-rule engine (account-posting-rules) is deliberately NOT an agent capability and is not exposed over MCP, so map any \"create a posting rule\" request to this org-memory rule rather than implying that engine exists.",
        "tags": ["Operational Rules"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["domain"],
                "properties": {
                  "domain": {
                    "type": "string",
                    "enum": ["classification", "cash_application"],
                    "description": "Use `classification` for transaction-categorization rules or `cash_application` for invoice/deposit matching memory."
                  },
                  "description_contains": {
                    "type": "string",
                    "description": "classification — literal memo/payee substring (bank/Plaid). Use ONLY for text the user quoted verbatim."
                  },
                  "source_category": {
                    "type": "string",
                    "description": "classification — source-platform metadata category (Plaid personal-finance category, Ramp accounting category). Preferred for a class of transactions."
                  },
                  "merchant_name": {
                    "type": "string",
                    "description": "classification — merchant match for card spend (Ramp/Raincard)."
                  },
                  "blockchain_address": {
                    "type": "string",
                    "description": "classification — from/to address match for crypto transactions."
                  },
                  "asset_type": {
                    "type": "string",
                    "description": "classification — asset match for crypto transactions (USDC, ETH, …)."
                  },
                  "direction": {
                    "type": "string",
                    "enum": ["DEPOSIT", "WITHDRAWAL", "ANY"],
                    "description": "classification — direction constraint. Defaults to ANY."
                  },
                  "category_ledger_account_id": {
                    "type": "string",
                    "description": "classification (required) — the ledger account matching transactions are classified to. Resolve via `tools.ledger_accounts.list_ledger_accounts`."
                  },
                  "resolved_payee": {
                    "type": "string",
                    "description": "classification — clean payee name for display."
                  },
                  "default_tag_id": {
                    "type": "string",
                    "description": "classification — tag applied to matching transactions."
                  },
                  "title": {
                    "type": "string",
                    "description": "cash_application (required) — short human-readable rule name."
                  },
                  "statement": {
                    "type": "string",
                    "description": "cash_application (required) — natural-language statement of the rule."
                  },
                  "cash_application_action": {
                    "type": "string",
                    "enum": ["route_to_account", "require_human_review"],
                    "description": "cash_application — defaults to `route_to_account`."
                  },
                  "matcher": {
                    "type": "string",
                    "description": "cash_application (required) — the exact phrase matched against invoice line descriptions / product names. Supply the SKU or product text verbatim."
                  },
                  "match_operator": {
                    "type": "string",
                    "enum": ["exact", "contains"],
                    "description": "cash_application — `contains` (default) fires when the line text contains the matcher anywhere; `exact` requires the normalized line label to equal it."
                  },
                  "ledger_account_id": {
                    "type": "string",
                    "description": "cash_application route_to_account (required) — target ledger account id. Resolve explicitly; it is not inferred from the rule text."
                  },
                  "ledger_account_name": {
                    "type": "string",
                    "description": "cash_application route_to_account (required) — name matching `ledger_account_id`, for display and auditing."
                  }
                }
              },
              "example": {
                "domain": "classification",
                "blockchain_address": "0x0000000000000000000000000000000000000000",
                "direction": "DEPOSIT",
                "category_ledger_account_id": "lac_000000000000000000000000"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rule saved (idempotent upsert — see `outcome`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["outcome", "rule_id", "domain", "version", "summary", "rule"],
                      "properties": {
                        "outcome": {
                          "type": "string",
                          "enum": ["created", "already_exists", "replaced"],
                          "description": "`created` — new rule; `already_exists` — an equivalent rule already covered this (no write); `replaced` — an equivalent matcher now routes to the new target."
                        },
                        "rule_id": {
                          "type": "string"
                        },
                        "domain": {
                          "type": "string",
                          "enum": ["classification", "cash_application"]
                        },
                        "version": {
                          "type": "string",
                          "description": "Version stamp of the rules file after the write."
                        },
                        "summary": {
                          "type": "string",
                          "description": "Human-readable outcome summary to paraphrase back to the user."
                        },
                        "rule": {
                          "$ref": "#/components/schemas/OperationalRule"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/operational-rules/{rule_id}": {
      "delete": {
        "operationId": "deleteOperationalRule",
        "summary": "Delete a Copilot-memory rule",
        "description": "Deletes one copilot memory rule by its `rule_id` (get it from `tools.operational_rules.list_operational_rules`). The optional `domain` query narrows which rule family is searched; without it the id is resolved across both. When the user CORRECTS a previously-taught routing (“actually X should go to Z, not Y”), delete or replace the old rule FIRST — otherwise it fires again on the copilot's next run. Unknown `rule_id` returns 404.",
        "tags": ["Operational Rules"],
        "parameters": [
          {
            "name": "rule_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The opaque rule id to delete (e.g. `rule_ab12cd34`)."
          },
          {
            "name": "domain",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["classification", "cash_application"]
            },
            "description": "Optional domain hint to narrow the delete target."
          }
        ],
        "responses": {
          "200": {
            "description": "Rule deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted_rule_id", "domain", "deleted_rule_summary", "version", "remaining_count"],
                      "properties": {
                        "deleted_rule_id": {
                          "type": "string"
                        },
                        "domain": {
                          "type": "string",
                          "enum": ["classification", "cash_application"]
                        },
                        "deleted_rule_summary": {
                          "type": "string",
                          "description": "Human-readable summary of the deleted rule."
                        },
                        "version": {
                          "type": "string",
                          "description": "Version stamp of the rules file after the write."
                        },
                        "remaining_count": {
                          "type": "integer",
                          "description": "Rules remaining in the affected domain after the delete."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/classifications": {
      "post": {
        "operationId": "classifyTransactions",
        "summary": "Classify transactions and post eligible entries asynchronously",
        "description": "Categorizes selected transactions with the same behavior as the in-app and Slack Copilot: the org-memory rule cascade derives each category and payment account, then immediately creates POSTED Entendre journal entries for high-confidence matches that have both required accounts. No preview or secondary approval call is required after the user asks to classify. Rows without a confident category or payment account remain pending_review or unclassified and are not posted.\nProvide sequence_numbers, transaction_ids, or both ISO dates. IDs and completed results are capped at 100 transactions, and date ranges at 92 days; narrow broader selectors. search_term cannot enforce the default unaccounted filter, so pass unaccounted_only: false explicitly when searching. The caller cannot disable auto-posting on this operation. Rules created through tools.operational_rules.create_operational_rule are inputs to the same engine.\nThis call returns a cls_ job immediately. Poll it with tools.jobs.get_background_job({ id: job_id }) in a later execute_code call—never sleep or spin inside one 60-second sandbox invocation. Report auto_posted, pending_review, unclassified, skipped, and failed outcomes. Classification creates Entendre journal entries but does not sync them to QuickBooks, Xero, or another GL; GL sync is a separate operation.",
        "x-mcp-typed-response": true,
        "x-mcp-direct-mutation": true,
        "tags": ["Classifications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "sequence_numbers": {
                    "type": "array",
                    "items": { "type": "string", "pattern": "^OT-[0-9]+$" },
                    "maxItems": 100,
                    "description": "OT-#### transaction sequence numbers—the easiest way to target exact transactions."
                  },
                  "transaction_ids": {
                    "type": "array",
                    "items": { "type": "string", "pattern": "^txn_" },
                    "maxItems": 100,
                    "description": "Canonical v1 transaction ids (txn_…). Bare internal ids are rejected."
                  },
                  "date_from": {
                    "type": "string",
                    "format": "date",
                    "description": "Inclusive ISO date. Must be paired with date_to; the range may not exceed 92 days."
                  },
                  "date_to": {
                    "type": "string",
                    "format": "date",
                    "description": "Inclusive ISO date. Must be paired with date_from; the range may not exceed 92 days."
                  },
                  "source_ids": {
                    "type": "array",
                    "items": { "type": "string", "pattern": "^fac_" },
                    "maxItems": 100,
                    "description": "Canonical financial-account ids (fac_…). Prefer source_types when the user names a platform."
                  },
                  "source_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "WALLET",
                        "EXCHANGE_SOURCE",
                        "RAINCARD",
                        "STAKING_SOURCE",
                        "REQUEST_FINANCE",
                        "RAIN_BILL_PAY",
                        "CREDIT_COOP",
                        "NIURAL_SOURCE",
                        "FIREBLOCKS",
                        "BANK_SOURCE",
                        "RAMP_CARD",
                        "RAMP_BANK_ACCOUNT",
                        "RAMP_STATEMENT",
                        "RAMP_REIMBURSEMENT",
                        "MANUAL_BANK_STATEMENT",
                        "FINCH_PAYROLL",
                        "STRIPE"
                      ]
                    },
                    "maxItems": 30,
                    "description": "Legacy engine source types such as WALLET, BANK_SOURCE, RAMP_CARD, RAINCARD, or EXCHANGE_SOURCE."
                  },
                  "legal_entity_ids": {
                    "type": "array",
                    "items": { "type": "string", "pattern": "^le_" },
                    "maxItems": 100
                  },
                  "classifications": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "SWAP",
                        "NON_TAXABLE_CONVERSION",
                        "DEPOSIT",
                        "WITHDRAWAL",
                        "BRIDGE",
                        "INTERCOMPANY TRANSFER",
                        "INTERNAL TRANSFER",
                        "FEE",
                        "MINTING",
                        "SPAM",
                        "NFT",
                        "UNKNOWN",
                        "STAKING_REWARD",
                        "VALIDATOR_REWARD",
                        "INVOICE",
                        "BILL",
                        "CLAIM REWARD",
                        "BORROW",
                        "RESERVES CHANGE",
                        "REPAYMENT",
                        "REALIZED_PNL",
                        "INCOME",
                        "EXPENSE",
                        "REFUND",
                        "CHARGEBACK"
                      ]
                    },
                    "maxItems": 30,
                    "description": "Canonical transaction types. Transfer values use spaces: INTERNAL TRANSFER and INTERCOMPANY TRANSFER."
                  },
                  "search_term": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256,
                    "description": "Case-insensitive text search. Must be accompanied by unaccounted_only: false."
                  },
                  "unaccounted_only": {
                    "type": "boolean",
                    "description": "Only transactions without journal entries. Defaults to true outside search mode."
                  },
                  "confidence_threshold": {
                    "type": "number",
                    "minimum": 0,
                    "maximum": 1,
                    "description": "Minimum confidence for the classified bucket. Default 0.85; do not lower without explicit user instruction."
                  }
                }
              },
              "example": {
                "date_from": "2026-06-01",
                "date_to": "2026-06-30",
                "source_types": ["WALLET"]
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Classification job accepted. Poll the returned cls_ id through get_background_job.",
            "headers": {
              "Location": {
                "schema": { "type": "string" },
                "description": "Polling URL under /v1/background-jobs/."
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ClassificationJobEnvelope" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/agents": {
      "post": {
        "operationId": "createAgent",
        "summary": "Create an agent",
        "description": "Creates a new agent instance under the authenticated organization. Agents are autonomous workers that run on a configured schedule (cron) and execute a fixed capability set declared at creation time. Returns 422 if the supplied description references unsupported capabilities. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Agents"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "description"],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name for the agent."
                  },
                  "description": {
                    "type": "string",
                    "description": "Human-readable description of what this agent should do."
                  },
                  "cron_expressions": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Cron expressions (5-field). Fields are wall-clock in the organization's timezone (the scheduler fires cron in the org's zone, not UTC). Example: [\"0 2 * * *\"] = daily at 02:00 in the org's timezone."
                  },
                  "schedule_description": {
                    "type": "string",
                    "description": "Natural language schedule as an alternative to `cron_expressions` (e.g., \"every weekday at 9am\")."
                  },
                  "legal_entities": {
                    "type": "array",
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "additionalProperties": false,
                      "required": ["id", "name"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 128
                        },
                        "name": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 256
                        }
                      }
                    },
                    "description": "Legal entities this agent operates on."
                  },
                  "sources": {
                    "type": "array",
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "additionalProperties": false,
                      "required": ["id", "name"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 128
                        },
                        "name": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 256
                        }
                      }
                    },
                    "description": "Financial account sources this agent operates on."
                  },
                  "emails": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "email"
                    },
                    "description": "Email addresses for run notifications."
                  },
                  "slack_channel_id": {
                    "type": ["string", "null"],
                    "description": "Slack channel ID for notifications."
                  },
                  "enabled_mcp_connection_ids": {
                    "type": "array",
                    "maxItems": 50,
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "description": "IDs of MCP connections this agent is allowed to use during its runs. Omit for none."
                  }
                }
              },
              "example": {
                "name": "Daily GL Reconciliation",
                "description": "Scan for duplicate transactions between Entendre and QuickBooks. Flag any duplicates or missing entries.",
                "cron_expressions": ["0 2 * * *"],
                "legal_entities": [
                  {
                    "id": "le_456def",
                    "name": "Acme Corp"
                  }
                ],
                "emails": ["accounting@company.com"],
                "slack_channel_id": "C04EXAMPLE"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Agent created successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Agent"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request body or cron expressions."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "Description references unavailable capabilities."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "get": {
        "operationId": "listAgents",
        "summary": "List agents",
        "description": "Returns the complete set of agents for the authenticated organization, ordered by name (ascending) — name, schedule, capabilities, `is_active` flag, and the latest run summary per agent. This endpoint returns a single complete page and accepts no pagination or filter query parameters (`has_more` is always `false` and `next_cursor` always `null`). Unsupported query parameters return 400 rather than being ignored. For a single agent by ID use `tools.agents.get_agent` instead.\n\nWhat makes each agent run: read the `triggers[]` array, not just `cron_expressions`/`schedule`, and check each trigger's `is_active`. An EVENT-driven agent (an ACTIVE `*_webhook` trigger — plaid/ramp/quickbooks/xero/email) has an empty `cron_expressions`/`schedule` but a populated `triggers[]`; report it as event-driven, NEVER as unscheduled. Only `is_active: true` triggers fire the agent — if every trigger is `is_active: false`, nothing runs it, so do not report it as scheduled or event-driven. Timezone: the `schedule` times are wall-clock in the organization's timezone. Do NOT guess or assume that zone — resolve the concrete IANA zone with `tools.organizations.get_current_organization` (its `timezone` field) and name it when you present a schedule; if that lookup is unavailable, say the zone is unconfirmed rather than assuming UTC or the viewer's local zone.",
        "tags": ["Agents"],
        "x-mcp-complete-collection": true,
        "responses": {
          "200": {
            "description": "Single complete page of agent instances (this endpoint does not paginate).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Agent"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/agents/runs/{runId}/approve": {
      "post": {
        "operationId": "approveAgentRun",
        "summary": "Approve a pending-review agent run",
        "description": "Approves a run that is in `pending_review` (a successful run of an instance with an assigned reviewer), transitioning it to `success` and stamping the approver + timestamp. Returns 409 if the run is not pending review (already approved, or auto-released after the reviewer was removed). Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Agents"],
        "parameters": [
          {
            "name": "runId",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "format": "uuid" },
            "description": "The agent run id (bare UUID)."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Run approved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/AgentRun" }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Agent run not found."
          },
          "409": {
            "description": "Run is not pending review (already approved or released)."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/agents/{id}": {
      "get": {
        "operationId": "getAgent",
        "summary": "Get an agent",
        "description": "Returns a single agent record with its most recent run embedded — name, capability set, schedule, `is_active` flag, and the latest run's status / output. Use this for ID-based lookup; for browsing the agent list use `tools.agents.list_agents` instead. Read-only.\n\nWhat makes the agent run: read the `triggers[]` array, not just `cron_expressions`/`schedule`, and check each trigger's `is_active`. An EVENT-driven agent (an ACTIVE `*_webhook` trigger — plaid/ramp/quickbooks/xero/email) has an empty `cron_expressions`/`schedule` but a populated `triggers[]`; report it as event-driven, NEVER as unscheduled. Only `is_active: true` triggers fire the agent — if every trigger is `is_active: false`, nothing runs it, so do not report it as scheduled or event-driven. Timezone: the `schedule` times are wall-clock in the organization's timezone. Do NOT guess that zone — resolve the concrete IANA zone with `tools.organizations.get_current_organization` (its `timezone` field) and name it when you present a schedule; if that lookup is unavailable, say the zone is unconfirmed rather than assuming UTC or the viewer's local zone.",
        "tags": ["Agents"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Agent instance with latest run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Agent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Agent not found."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateAgent",
        "summary": "Update an agent",
        "description": "Updates mutable fields on a single agent — `name`, `description`, `is_active`, the schedule (`cron_expressions` + `schedule_description`), notification targets (`emails`, `slack_channel_id`), the `owner_user_id` / `reviewer_user_id` assignments, the data scope (`legal_entities` / `sources`), and the connected MCP tools (`enabled_mcp_connection_ids`) are all editable — each array REPLACES the existing value. Recreate the agent via `tools.agents.create_agent` only when its core task (what the `description` tells it to do) must change. Pass `idempotencyKey` to make the call replay-safe.\n\nA `legal_entities` / `sources` change applies to the agent's very next scheduled, webhook, or manual (\"run now\") run. The run-time data scope is read from the agent's saved configuration each time it runs, so no `description` edit is needed to make a scope change take effect. Scope governs the runs where the agent executes its own task: scheduled, webhook, and manual. It deliberately does NOT apply when someone invokes the agent conversationally by @-mention in a Slack or Telegram thread — that turn answers the person's question for the whole organization by design, so do not describe it to a user as a scoped run.",
        "tags": ["Agents"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Updated display name."
                  },
                  "description": {
                    "type": "string",
                    "description": "Updated free-text description."
                  },
                  "is_active": {
                    "type": "boolean",
                    "description": "Set to false to pause, true to resume."
                  },
                  "cron_expressions": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Replacement schedule as cron expressions."
                  },
                  "schedule_description": {
                    "type": "string",
                    "description": "Human-readable schedule label."
                  },
                  "emails": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Notification email recipients."
                  },
                  "slack_channel_id": {
                    "type": ["string", "null"],
                    "description": "Slack channel id for notifications, or null to clear."
                  },
                  "owner_user_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `usr_` id of the owner to assign, or null to clear."
                  },
                  "reviewer_user_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed `usr_` id of the reviewer to assign, or null to clear."
                  },
                  "legal_entities": {
                    "type": "array",
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "additionalProperties": false,
                      "required": ["id"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 128
                        },
                        "name": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 256
                        }
                      }
                    },
                    "description": "Legal entities this agent operates on. REPLACES the existing scope (the array is not merged element-wise); send the full desired set. `name` is OPTIONAL and ignored on update — send `[{ id }]`, e.g. the ids read back from the agent's `arguments.legalEntityIds`. Any `name` sent is discarded: the run-time scope statement uses ids only, so no display name is stored or resolved."
                  },
                  "sources": {
                    "type": "array",
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "additionalProperties": false,
                      "required": ["id"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 128
                        },
                        "name": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 256
                        }
                      }
                    },
                    "description": "Financial account sources this agent operates on. REPLACES the existing scope (the array is not merged element-wise); send the full desired set. `name` is OPTIONAL and ignored on update — send `[{ id }]`, e.g. the ids read back from the agent's `arguments.sourceIds`. Any `name` sent is discarded: the run-time scope statement uses ids only, so no display name is stored or resolved."
                  },
                  "enabled_mcp_connection_ids": {
                    "type": "array",
                    "maxItems": 50,
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "description": "IDs of MCP connections this agent may use during its runs. REPLACES the existing set; send the full desired list, or an empty array to clear."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Agent updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Agent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Agent not found."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteAgent",
        "summary": "Delete an agent",
        "description": "Deletes a single agent as a DESTRUCTIVE hard-delete, permanently removing the agent record and all of its accumulated run history. The agent stops executing on its schedule immediately. To pause an agent while preserving its history, use `tools.agents.update_agent` with `is_active: false` instead. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Agents"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "204": {
            "description": "Agent deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Agent not found."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/api-keys/me": {
      "get": {
        "operationId": "getApiKeyMe",
        "summary": "Get current API key metadata",
        "description": "Returns metadata about the API key authenticating the request.\n\n### Fields\n\n- Prefix and last four characters.\n- Permissions.\n- Lifecycle timestamps.\n- `can_mutate` flag — gates v1 write endpoints.\n\nNever returns the plaintext key. The only path that emits plaintext is `POST /v1/api-keys/rotate`, which returns it once.\n\n### When to use\n\nConfirm which key is in use, whether it is `isActive`, and when it was last rotated.\n\n### Edge cases\n\n- Returns `404` if no key has been minted for the org yet — call `POST /v1/api-keys/rotate` from the dashboard to create one.\n- `can_mutate` is `true` only when the key carries a `createdByUserId` (set on rotation from a JWT session). Auto-generated keys and pre-Wave-3 keys are read-only.",
        "tags": ["API Keys"],
        "responses": {
          "200": {
            "description": "API key metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["keyPrefix", "keyLastFour", "permissions", "isActive", "can_mutate"],
                      "properties": {
                        "keyPrefix": {
                          "type": "string",
                          "description": "Key prefix (e.g. `ek_live_`)."
                        },
                        "keyLastFour": {
                          "type": "string",
                          "description": "Last 4 characters of the plaintext key."
                        },
                        "permissions": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "isActive": {
                          "type": "boolean",
                          "description": "False after revocation."
                        },
                        "revokedAt": {
                          "type": ["string", "null"],
                          "format": "date-time"
                        },
                        "lastUsedAt": {
                          "type": ["string", "null"],
                          "format": "date-time"
                        },
                        "usedCount": {
                          "type": "integer",
                          "minimum": 0
                        },
                        "createdAt": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "rotatedAt": {
                          "type": ["string", "null"],
                          "format": "date-time"
                        },
                        "can_mutate": {
                          "type": "boolean",
                          "description": "True when the key carries a `createdByUserId` (set by `POST /api-keys/rotate` from a JWT session). When false, v1's `requireUser()` gate 403s every mutation. Auto-gen keys and pre-Wave-3 keys default to false."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No API key minted for this organization yet. Call `POST /api-keys/rotate` from the dashboard."
          }
        }
      }
    },
    "/api-keys/rotate": {
      "post": {
        "operationId": "rotateApiKey",
        "summary": "Rotate the organization's API key",
        "description": "Rotates the calling organization's API key. Generates a fresh plaintext and returns it **EXACTLY ONCE** in the response — it is never displayed again.\n\n### Grace window\n\nOn rotation, the previous key stays valid for **24 hours** so customers can deploy the new plaintext without an instantaneous cutover. (Mirrors the Stripe / AWS dual-key pattern.) To kill the previous key immediately, call `POST /api-keys/revoke` first and then rotate.\n\n### Authentication: JWT-only\n\nRequires a logged-in dashboard user session. A stolen `ek_live_*` key cannot rotate itself — that would let an attacker mint a fresh key and lock the legitimate owner out of their own integration. Matches the Stripe / GitHub / OpenAI security posture (key issuance is dashboard-only).\n\nCalls from an API-key authenticated context return `403` with a `Sign in via the dashboard and retry` message.\n\n### Mutation gate\n\nThe rotated key carries the JWT user's id as `createdByUserId`, which unblocks v1's `requireUser()` mutation gate — every `POST` / `PATCH` / `DELETE` v1 endpoint becomes callable. Auto-generated keys (minted by the org-creation hook) have no `createdByUserId` and stay deliberately read-only until first rotated.\n\n### Scope\n\nOmit `permissions` to PRESERVE the existing key's stored scope (auto-provisioned keys with no stored scope fall back to `[\"apis.all\"]`). When provided, invalid scopes return `400` before any state change.",
        "tags": ["API Keys"],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "permissions": { "$ref": "#/components/schemas/ApiKeyPermissions" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rotated key. The `apiKey` plaintext is shown ONCE and never returned again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["apiKey", "keyPrefix", "keyLastFour", "permissions"],
                      "properties": {
                        "apiKey": {
                          "type": "string",
                          "description": "Full plaintext key. STORE NOW — never returned again."
                        },
                        "keyPrefix": {
                          "type": "string"
                        },
                        "keyLastFour": {
                          "type": "string"
                        },
                        "permissions": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "rotatedAt": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "previousKeyHashExpiresAt": {
                          "type": ["string", "null"],
                          "format": "date-time",
                          "description": "When the previous key stops authenticating. Null on first creation."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid permissions scope."
          },
          "403": {
            "description": "Caller is API-key authenticated (not JWT). Sign in via the dashboard and retry — keys cannot rotate themselves."
          }
        }
      }
    },
    "/api-keys/revoke": {
      "post": {
        "operationId": "revokeApiKey",
        "summary": "Revoke the organization's API key",
        "description": "Revokes the organization's API key. **DESTRUCTIVE and irreversible at the auth layer** — the old plaintext stops authenticating on the next call.\n\nSets `isActive: false` and clears any in-flight 24h grace-window `previousKeyHash` in one atomic write, so the panic button leaves nothing alive. Distinct from `POST /api-keys/rotate`, which keeps the old hash valid for 24h — revoke is the cleanest stop.\n\n### When to use\n\n- The plaintext was leaked (committed to a public repo, posted in chat, captured in a support log).\n- The integration is being decommissioned.\n\nTo mint a replacement after revoking, call `POST /api-keys/rotate` from a dashboard session.\n\n### Authentication: JWT-only\n\nSame as rotation. A stolen API key cannot revoke itself — that would let an attacker hand the legitimate owner a `401` instead of letting them recover. Calls from an API-key authenticated context return `403` with a `Sign in via the dashboard and retry` message.\n\nThis is the v1 surface for the question \"my key leaked, what do I do?\" — point customers here from the dashboard's key-management page.",
        "tags": ["API Keys"],
        "responses": {
          "200": {
            "description": "Key revoked. Returns the post-revoke metadata so the dashboard can render the dead-key state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["keyPrefix", "keyLastFour", "isActive"],
                      "properties": {
                        "keyPrefix": {
                          "type": "string"
                        },
                        "keyLastFour": {
                          "type": "string"
                        },
                        "revokedAt": {
                          "type": ["string", "null"],
                          "format": "date-time"
                        },
                        "isActive": {
                          "type": "boolean",
                          "description": "Always false after revoke succeeds."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Caller is API-key authenticated (not JWT). Sign in via the dashboard and retry — keys cannot revoke themselves."
          },
          "404": {
            "description": "No API key exists for this organization."
          }
        }
      }
    },
    "/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "summary": "List webhooks",
        "description": "Returns webhook subscriptions for the authenticated organization in reverse chronological order (newest first) — `endpoint`, `event_types` filter, `status`, and retry stats. The HMAC signing `secret` is NOT included in list responses; it is returned exactly once on creation and never again. Optionally filter by `status` and request a total via `include_count`. Cursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter by subscription status. Case-insensitive.",
            "schema": {
              "type": "string",
              "enum": ["active", "paused", "disabled"]
            }
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "include_count",
            "in": "query",
            "required": false,
            "description": "When `true`, include the total matching count in the response. Off by default to avoid the extra count query.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of webhooks.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Webhook"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Total matching subscriptions. Present only when `include_count=true` was passed."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "whk_345def",
                      "organization_id": "org_123abc",
                      "endpoint": "https://api.yourapp.com/webhooks/entendre",
                      "event_types": ["entendre.transaction.created", "entendre.gl_sync.*"],
                      "status": "active",
                      "description": "Production Sync Listener",
                      "failure_count": 0,
                      "last_delivered_at": "2024-03-10T15:02:00Z",
                      "created_by": "usr_789ghi",
                      "created_at": "2024-03-10T14:30:00Z",
                      "updated_at": "2024-03-10T14:30:00Z"
                    }
                  ],
                  "has_more": false,
                  "next_cursor": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "summary": "Create a webhook",
        "description": "Creates a webhook subscription — registers an HTTPS `endpoint` plus an `event_types` filter of `entendre.<resource>.<action>` glob patterns (use `[\"*\"]` to subscribe to every event, or family globs like `entendre.transaction.*`). Each pattern must match at least one known event type or the request is rejected with 400, so a typo can't silently never deliver. Payloads are signed with HMAC-SHA256 using the returned `secret`; the signature is in the `Entendre-Signature` header over the raw request body. Failed deliveries (HTTP 5xx or network timeout) are retried with exponential backoff; after 50 consecutive failures the subscription is auto-`disabled`. To suspend delivery without removing the subscription, PATCH `status: \"paused\"`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["endpoint", "event_types"],
                "properties": {
                  "endpoint": {
                    "type": "string",
                    "format": "uri",
                    "description": "HTTPS endpoint to receive events. Must be a public HTTPS URL (no private/loopback hosts)."
                  },
                  "event_types": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 50,
                    "items": {
                      "type": "string"
                    },
                    "description": "Event-type glob patterns of the form `entendre.<resource>.<action>` (lowercase, dot-separated, optional trailing `*`). Use `[\"*\"]` for all events. Each pattern must match at least one known event type (see the `event_type` enum on the WebhookEvent schema for the full catalog)."
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Optional caller-supplied label."
                  }
                }
              },
              "example": {
                "endpoint": "https://api.yourapp.com/webhooks/entendre",
                "event_types": ["entendre.transaction.created", "entendre.gl_sync.*"],
                "description": "Production Sync Listener"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Webhook created. The `secret` is only returned once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/WebhookWithSecret"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "whk_345def",
                    "organization_id": "org_123abc",
                    "endpoint": "https://api.yourapp.com/webhooks/entendre",
                    "event_types": ["entendre.transaction.created", "entendre.gl_sync.*"],
                    "status": "active",
                    "description": "Production Sync Listener",
                    "failure_count": 0,
                    "last_delivered_at": null,
                    "created_by": "usr_789ghi",
                    "secret": "whsec_...",
                    "created_at": "2024-03-10T14:30:00Z",
                    "updated_at": "2024-03-10T14:30:00Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/webhooks/{id}": {
      "patch": {
        "operationId": "updateWebhook",
        "summary": "Update a webhook",
        "description": "Updates any subset of a webhook subscription's mutable fields — `endpoint`, `description`, `event_types`, and `status`. Only `active` and `paused` are caller-toggleable; `disabled` is reserved for the auto-disable path (50 consecutive failures), and re-enabling an auto-disabled subscription is done by sending `status: \"active\"` (which also resets `failure_count`). The HMAC signing `secret` is preserved across updates and cannot be rotated here (recreate the subscription to rotate). An empty body is rejected with 400. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "endpoint": {
                    "type": "string",
                    "format": "uri",
                    "description": "HTTPS endpoint to receive events."
                  },
                  "event_types": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 50,
                    "items": {
                      "type": "string"
                    },
                    "description": "Replacement set of `entendre.<resource>.<action>` glob patterns. Each must match at least one known event type."
                  },
                  "description": {
                    "type": ["string", "null"],
                    "maxLength": 500
                  },
                  "status": {
                    "type": "string",
                    "enum": ["active", "paused"],
                    "description": "Caller-toggleable status. Case-insensitive. Sending `active` re-enables an auto-disabled subscription and resets `failure_count`."
                  }
                }
              },
              "example": {
                "endpoint": "https://api.yourapp.com/webhooks/entendre/v2",
                "event_types": ["*"],
                "status": "paused"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Webhook updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Webhook"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "whk_345def",
                    "organization_id": "org_123abc",
                    "endpoint": "https://api.yourapp.com/webhooks/entendre/v2",
                    "event_types": ["*"],
                    "status": "paused",
                    "description": "Production Sync Listener",
                    "failure_count": 0,
                    "last_delivered_at": "2024-03-10T15:02:00Z",
                    "created_by": "usr_789ghi",
                    "created_at": "2024-03-10T14:30:00Z",
                    "updated_at": "2024-03-11T09:12:00Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook",
        "description": "Removes a webhook subscription from the organization, immediately stopping event delivery to that endpoint. DESTRUCTIVE — pending deliveries in the retry queue for this webhook are dropped, and the HMAC signing secret is invalidated. To pause delivery without deleting the subscription, PATCH the webhook's `status` to `paused` via `update_webhook` instead. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "204": {
            "description": "Webhook deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "get": {
        "operationId": "getWebhook",
        "summary": "Get a webhook subscription",
        "description": "Returns a single outbound webhook subscription by its `whk_` id. Read-only. The signing secret is never included here — it is returned exactly once, on `POST /v1/webhooks` at creation time. Returns 404 when the subscription does not exist or belongs to another organization.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `whk_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The webhook subscription.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Webhook"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/bulk-add-tags": {
      "post": {
        "operationId": "bulkAddTagsToJournalEntries",
        "summary": "Add tags to multiple journal entries",
        "description": "Tags up to 50 journal entries by appending the supplied tag IDs to every line of every target entry. Does NOT replace existing tags — pass an empty tag set or use a separate remove call if replacement is desired. Returns which entries succeeded and which were not found. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 50,
                    "description": "Prefixed JE IDs (je_...)"
                  },
                  "tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed tag IDs (tag_...)"
                  }
                },
                "required": ["journal_entry_ids", "tag_ids"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Bulk tag result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkTagResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/bulk-reclass": {
      "post": {
        "operationId": "bulkReclassJournalEntries",
        "summary": "Reclassify journal entries to a different ledger account",
        "description": "Reclassifies `POSTED` journal entries (up to 50 when selecting by `journal_entry_ids` / `transaction_ids`; up to 200 when reclassifying an entire `from_ledger_account_id` with no id selectors) by unposting them, swapping the ledger account from `from_ledger_account_id` to `to_ledger_account_id`, and re-posting under the new account.\n\n**DESTRUCTIVE** — mutates the live ledger; both ledger-account balances move in opposite directions and the entries' linkage changes.\n\n### Dry run\n\nSupports a `dry_run` flag that previews the affected entry set without writing — it returns `{ dry_run, would_process, skipped, skipped_reasons, samples }` and moves no balances. Call with `dry_run: true` first when the input population is broad.\n\n### Scope filters\n\n- `journal_entry_ids` — operate on a specific set.\n- `transaction_ids` — narrow by linked transactions.\n\n### Choose the right operation\n\n- **Unpost** (`POST /v1/journal-entries/{id}/unpost`) — unpost without re-posting.\n- **Reverse** (`POST /v1/journal-entries/reverse`) — when audit policy requires the originals to stay visible alongside a mirror.\n- **Delete** (`POST /v1/journal-entries/delete`) — only on `DRAFT` / `ERROR` entries.\n- **Post** (`POST /v1/journal-entries/post`) — promote DRAFT entries to the ledger.\n\nPass `idempotencyKey` to make the call replay-safe.\n\nOptionally pass `confirm_count` equal to the number of matched journal entries (preview it with `dry_run: true` first); the request is rejected (400) if it does not match, guarding against an over-broad selection. To roll back without re-posting, use `tools.journal_entries.bulk_unpost_journal_entries`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 50,
                    "description": "Prefixed JE IDs (je_...)"
                  },
                  "transaction_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 50,
                    "description": "Prefixed transaction IDs (txn_...)"
                  },
                  "from_ledger_account_id": {
                    "type": "string",
                    "description": "Source ledger account — only JE lines targeting this account are reclassified (lac_...). Optional: when omitted, the source is auto-detected per entry as the single non-clearing line whose account isn't the target (ambiguous entries are skipped). Required when reclassing by account alone (no journal_entry_ids / transaction_ids)."
                  },
                  "to_ledger_account_id": {
                    "type": "string",
                    "description": "Target ledger account (lac_...)"
                  },
                  "dry_run": {
                    "type": "boolean",
                    "default": false,
                    "description": "Preview changes without modifying data"
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the resolved match count (preview it with dry_run:true), otherwise the request is rejected with 400. Rejected when the match set was truncated — narrow the filter instead."
                  }
                },
                "required": ["to_ledger_account_id"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reclass result (or dry_run preview).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BulkReclassResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/bulk-unpost": {
      "post": {
        "operationId": "bulkUnpostJournalEntries",
        "summary": "Filter-based bulk unpost journal entries",
        "description": "Unposts every journal entry matching the supplied filter (not an id list). Uses VOID semantics: transaction links are detached (`hasAccounting:false`), balances reversed, and synced ERP copies removed best-effort. Status defaults to POSTED + REVERSED. `modified_count` confirms the Entendre-side change only; it does not prove that an external-GL copy was removed.\n\n**DESTRUCTIVE.** At least one filter field is required — an empty body is rejected. Synchronous; the match set is capped at 5000 (a `message` notes truncation).\n\nPass `idempotencyKey` to make the call replay-safe.\n\n### Safe operation workflow\n\n1. Call once with `dry_run: true` to preview the match set without mutating — the response returns `{ dry_run, summary: { matched, truncated }, eligible: [...] }`.\n2. Re-call without `dry_run`, setting `confirm_count` to the matched count from the preview; it is rejected (400) if it does not match, or if the match set was truncated (narrow the filter and retry). Related bulk ops: `tools.journal_entries.bulk_post_journal_entries`, `tools.journal_entries.bulk_reverse_journal_entries`, `tools.journal_entries.bulk_delete_journal_entries`, `tools.journal_entries.bulk_reclass_journal_entries`.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JournalEntryFilterBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Unpost result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "modified_count": {
                          "type": "integer"
                        },
                        "updated_journal_entry_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/bulk-delete": {
      "post": {
        "operationId": "bulkDeleteJournalEntriesByFilter",
        "summary": "Filter-based bulk delete journal entries",
        "description": "Soft-deletes every DRAFT / ERROR / UNPOSTED, non-synced journal entry matching the supplied filter (not an id list). Posted or synced entries are never deleted. Status defaults to DRAFT + ERROR + UNPOSTED.\n\n**DESTRUCTIVE.** At least one filter field is required — an empty body is rejected. Synchronous; the match set is capped at 5000 (a `message` notes truncation). The id-list `POST /journal-entries/delete` remains available for explicit-id deletes.\n\nPass `idempotencyKey` to make the call replay-safe.\n\n### Safe operation workflow\n\n1. Call once with `dry_run: true` to preview the match set without mutating — the response returns `{ dry_run, summary: { matched, truncated }, eligible: [...] }`.\n2. Re-call without `dry_run`, setting `confirm_count` to the matched count from the preview; it is rejected (400) if it does not match, or if the match set was truncated (narrow the filter and retry).",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JournalEntryFilterBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Delete result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "deleted_count": {
                          "type": "integer"
                        }
                      }
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/bulk-line-tags": {
      "post": {
        "operationId": "bulkEditJournalEntryLineTags",
        "summary": "Add/remove tags on journal-entry lines",
        "description": "Adds and/or removes tags on the lines of selected journal entries, optionally restricted to lines whose ledger account is one of `ledger_account_types`. A tag is propagated to the parent entry once one of its lines carries it, and removed from the entry once its last tagged line loses it.\n\nSelect entries by `journal_entry_ids`, `journal_sequence_numbers`, OR `template_ids` (at least one). Provide at least one of `add_tag_ids` / `remove_tag_ids`.\n\nPass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 5000,
                    "description": "Prefixed JE IDs (je_...)"
                  },
                  "journal_sequence_numbers": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Sequence numbers (e.g. JE-2096) or bare integers"
                  },
                  "template_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 5000,
                    "description": "Prefixed template IDs (tpl_...) — selects every JE generated by these templates"
                  },
                  "add_tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed tag IDs (tag_...) to add"
                  },
                  "remove_tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed tag IDs (tag_...) to remove"
                  },
                  "ledger_account_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": ["Asset", "Liability", "Equity", "Income", "Expense"]
                    },
                    "description": "Restrict to lines of these ledger-account types (case-insensitive); omit for all types"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Line-tag result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "total_updated_lines": {
                          "type": "integer"
                        },
                        "total_journal_entries": {
                          "type": "integer"
                        },
                        "updated_journal_entry_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "missing_je_numbers": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/template-line-tags": {
      "post": {
        "operationId": "addTemplateLineTag",
        "summary": "Add a tag to journal-entry template lines",
        "description": "Add a tag to the lines of the given journal-entry templates, optionally restricted to lines whose ledger account is one of `ledger_account_types`. Templates not owned by the caller's organization are skipped.\n\nPass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tag_id": {
                    "type": "string",
                    "description": "Prefixed tag ID (tag_...)"
                  },
                  "template_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 5000,
                    "description": "Prefixed template IDs (tpl_...)"
                  },
                  "ledger_account_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": ["Asset", "Liability", "Equity", "Income", "Expense"]
                    },
                    "description": "Restrict to template lines of these ledger-account types (case-insensitive); omit for all types"
                  }
                },
                "required": ["tag_id", "template_ids"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Template-line tag result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "updated_lines": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/lines/{lineId}/tags": {
      "patch": {
        "operationId": "updateJournalEntryLineTags",
        "summary": "Update tags on a journal entry line",
        "description": "Updates the tags on one line of one journal entry in one of three mutually exclusive modes — `tag_ids` replaces the full tag set, `add_tag_ids` appends without removing, `remove_tag_ids` removes specific tags. `add_tag_ids` and `remove_tag_ids` may be combined. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "name": "lineId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Journal entry line ID (jel_...)"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Full replace - set exactly these tags"
                  },
                  "add_tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Append these tags (combinable with remove_tag_ids)"
                  },
                  "remove_tag_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Remove these tags (combinable with add_tag_ids)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated line tags.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/LineTagResult"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/organizations/members": {
      "get": {
        "operationId": "listOrganizationMembers",
        "summary": "List organization members",
        "description": "Returns all members (active plus invited) of the current organization with role, email, and invitation status.\n\n### Breaking: default-mask migration\n\nAs of the v1 hardening pass, the `email` field defaults to a **masked form** (for example `o***@entendre.finance`) — earlier versions returned the full unmasked address. The change protects against AI-agent answers surfacing full PII into chat transcripts that may travel outside the trust boundary (audit, support tickets, screenshots, training data).\n\nExisting integrators relying on the full email must pass `?include_pii=true` to preserve the previous response shape. Calls without it now return the masked form.\n\n**Migration**: forward `?include_pii=true` on contexts where the caller is trusted to handle PII — dashboard UI, internal admin pipeline, HR sync. The mask is intentionally lossy (`omar` and `oscar` both render as `o***@<domain>`), so it cannot be reversed.\n\n### Related endpoints\n\n- `POST /v1/organizations/members/invite` — invite a new member.\n- `PATCH /v1/organizations/members/{id}` — update a member's role.\n\n### Pagination\n\nCursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["Organizations"],
        "parameters": [
          {
            "name": "include_pii",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, returns the full unmasked email per member. Defaults to `false` — emails come back masked as `<first-char>***@<domain>`. The mask is intentionally lossy: `omar@entendre.finance` and `oscar@entendre.finance` both render as `o***@entendre.finance`. Use the unmasked path only from contexts where the caller is trusted to handle the PII (dashboard UI, internal admin tooling). Agents writing customer-facing chat replies should leave this off."
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Members listed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OrganizationMember"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/organizations/members/invite": {
      "post": {
        "operationId": "inviteOrganizationMember",
        "summary": "Invite a member",
        "description": "Invites a user to join the current organization by sending an invitation email — the invitee appears in the member roster with `status: invited` until they accept. To list pending and active members together use `tools.organizations.list_organization_members`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Organizations"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "role"],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "role": {
                    "type": "string",
                    "enum": ["admin", "accountant", "auditor", "analyst"],
                    "description": "Organization role. Case-insensitive on input."
                  }
                }
              },
              "example": {
                "email": "new.member@company.com",
                "role": "accountant"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Invitation sent.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/OrganizationMember"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/organizations/members/{id}": {
      "patch": {
        "operationId": "updateOrganizationMember",
        "summary": "Update a member",
        "description": "Updates an existing organization member's role (`admin`, `accountant`, `auditor`, or `analyst`). The membership and email are immutable; to fully remove the member use `tools.organizations.remove_organization_member` instead. Changing the last remaining admin's role is rejected with 409. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Organizations"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["role"],
                "properties": {
                  "role": {
                    "type": "string",
                    "enum": ["admin", "accountant", "auditor", "analyst"],
                    "description": "New organization role. Case-insensitive on input."
                  }
                }
              },
              "example": {
                "role": "admin"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Member updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/OrganizationMember"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "removeOrganizationMember",
        "summary": "Remove a member",
        "description": "Removes a member from the current organization as a DESTRUCTIVE action — the member loses access immediately and cannot be re-invited under the same email without first re-creating the invite. To change a member's role without removing them use `tools.organizations.update_organization_member` instead. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Organizations"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "204": {
            "description": "Member removed."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/users/me": {
      "get": {
        "operationId": "getCurrentUser",
        "summary": "Get current user",
        "description": "Returns the profile of the user behind the authenticated API key or OAuth token — name, email, phone, address, and email-verification status. Read-only. For the organization metadata use `tools.organizations.get_current_organization`.",
        "tags": ["Users"],
        "responses": {
          "200": {
            "description": "User profile retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/User"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "usr_abc123",
                    "email": "user@company.com",
                    "name": {
                      "first": "Jane",
                      "last": "Doe"
                    },
                    "phone_number": "(555) 123-4567",
                    "address": {
                      "line1": "123 Main St",
                      "city": "San Francisco",
                      "state": "CA",
                      "country": "US"
                    },
                    "billing_email": "billing@company.com",
                    "email_verified": true,
                    "created_at": "2024-01-15T08:00:00Z",
                    "updated_at": "2024-03-01T12:00:00Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateCurrentUser",
        "summary": "Update current user",
        "description": "Updates profile fields for the authenticated user — `name` (first / last), `phone_number`, `address`, and `billing_email`. Only supplied fields are changed; omitted fields retain their existing values. At least one field is required. Login email is not editable here. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Users"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "object",
                    "properties": {
                      "first": {
                        "type": "string"
                      },
                      "last": {
                        "type": "string"
                      }
                    }
                  },
                  "phone_number": {
                    "type": ["string", "null"]
                  },
                  "address": {
                    "type": "object",
                    "properties": {
                      "line1": {
                        "type": ["string", "null"]
                      },
                      "line2": {
                        "type": ["string", "null"]
                      },
                      "city": {
                        "type": ["string", "null"]
                      },
                      "state": {
                        "type": ["string", "null"]
                      },
                      "country": {
                        "type": ["string", "null"]
                      }
                    }
                  },
                  "billing_email": {
                    "type": ["string", "null"],
                    "format": "email"
                  }
                }
              },
              "example": {
                "name": {
                  "first": "Jane",
                  "last": "Smith"
                },
                "billing_email": "billing@company.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "User updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/User"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/settings/revaluation": {
      "get": {
        "operationId": "getRevaluationSettings",
        "summary": "Get revaluation settings",
        "description": "Returns the unrealized gain / loss revaluation configuration for a legal entity — which ledger accounts gain and loss are posted to, and whether the org runs in `single` (one account) or `tuple` (separate gain / loss accounts) mode. Read-only. To change the configuration use `tools.settings.update_revaluation_settings`.",
        "tags": ["Settings"],
        "parameters": [
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed legal entity ID (`le_`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Revaluation settings retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/RevaluationSettings"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "put": {
        "operationId": "updateRevaluationSettings",
        "summary": "Update revaluation settings",
        "description": "Updates the unrealized gain / loss revaluation configuration for a legal entity. Pass `setting: \"single\"` for one combined gain-or-loss account or `setting: \"tuple\"` for separate gain and loss accounts. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Settings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["legal_entity_id", "setting"],
                "properties": {
                  "legal_entity_id": {
                    "type": "string"
                  },
                  "setting": {
                    "type": "string",
                    "enum": ["single", "tuple"]
                  },
                  "ledger_account_id": {
                    "type": "string",
                    "description": "Required when `setting=single`."
                  },
                  "gain_ledger_account_id": {
                    "type": "string",
                    "description": "Required when `setting=tuple`."
                  },
                  "loss_ledger_account_id": {
                    "type": "string",
                    "description": "Required when `setting=tuple`."
                  }
                }
              },
              "example": {
                "legal_entity_id": "le_abc123",
                "setting": "tuple",
                "gain_ledger_account_id": "lac_def456",
                "loss_ledger_account_id": "lac_ghi789"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revaluation settings updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/RevaluationSettings"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/settings/job-config": {
      "get": {
        "operationId": "getJobConfig",
        "summary": "Get job configuration",
        "description": "Returns the cron schedule and run-frequency configuration for a specific background-job type. Read-only. To change the schedule use `tools.settings.update_job_config`.",
        "tags": ["Settings"],
        "parameters": [
          {
            "name": "job_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Job type identifier (e.g., `ruleset`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Job configuration retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/JobConfig"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "put": {
        "operationId": "updateJobConfig",
        "summary": "Update job configuration",
        "description": "Updates the cron schedule for a background-job type. Changes take effect on the next scheduler reload (within ~60s). Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Settings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["job_type", "cron_expression"],
                "properties": {
                  "job_type": {
                    "type": "string"
                  },
                  "cron_expression": {
                    "type": "string",
                    "description": "Standard 5-field cron expression."
                  }
                }
              },
              "example": {
                "job_type": "ruleset",
                "cron_expression": "0 */6 * * *"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job configuration updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/JobConfig"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/jobs/{id}/progress": {
      "get": {
        "operationId": "getJobProgress",
        "summary": "Get job progress",
        "description": "Returns detailed progress for a specific running or recently completed job — processed-vs-total counts and percentage. Use this for live progress bars. For a one-shot status check use `tools.jobs.get_job_status`; for the full job history list use `tools.jobs.get_job_history`.",
        "tags": ["Jobs"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Job progress retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/JobProgress"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "job_abc123",
                    "job_type": "sync",
                    "status": "active",
                    "to_process_count": 500,
                    "completed_count": 225,
                    "progress": 45,
                    "message": "Processing transactions...",
                    "created_at": "2024-03-15T10:00:00Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/jobs/history": {
      "get": {
        "operationId": "getJobHistory",
        "summary": "List job history",
        "description": "Returns a paginated list of past background-job executions in reverse chronological order — job type, status, started / completed timestamps, and final summary. Optionally filter by `status` and `job_type`. For live progress on a running job use `tools.jobs.get_job_progress`; for a single job's status use `tools.jobs.get_job_status`. Cursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["Jobs"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter by job status. Case-insensitive.",
            "schema": {
              "type": "string",
              "enum": ["STARTED", "IN_PROGRESS", "COMPLETED", "JOB_FAILED", "CANCELED", "HANGED"]
            }
          },
          {
            "name": "job_type",
            "in": "query",
            "required": false,
            "description": "Filter by recurring job type. Case-insensitive.",
            "schema": {
              "type": "string",
              "enum": [
                "IMPORT_TRANSACTION_JOB",
                "PLAID_TRANSACTION_IMPORT_JOB",
                "RAMP_TRANSACTION_IMPORT_JOB",
                "RULESET_JOB",
                "RULE_METRICS_JOB",
                "SYNC_JOURNAL_ENTRY_JOB",
                "IMPORT_EXCHANGE_SOURCE_JOB",
                "ORGANIZATION_RULESET_JOB",
                "RAINCARDS_CARD_JOB",
                "RAINCARDS_TRANSACTION_JOB",
                "LOOP_TRANSACTION_JOB",
                "JOURNAL_ENTRY_RULE_MATCH_JOB",
                "TRANSACTION_CLASSIFICATION",
                "JOURNAL_ENTRY_VOLUME",
                "PROCESS_BATCH_JOURNAL_ENTRY_VOLUME",
                "STAKING_SOURCE_JOB",
                "REQUEST_FINANCE_JOB",
                "REQUEST_UPDATE_INVOICE_FINANCE_JOB",
                "EXPORT_CSV_JOB",
                "CREDIT_COOP_TRANSACTION_IMPORT_JOB",
                "UPDATE_GL_JOURNAL_TAGS",
                "ASSET_REVALUATION_JOB",
                "ASSET_REVALUATION_BULK_JOB",
                "ASSET_REVALUATION_AD_HOC_JOB",
                "CLOSE_ACCOUNTING_PERIOD_BULK_JOB",
                "REOPEN_ACCOUNTING_PERIOD_BULK_JOB",
                "BALANCE_RECONCILIATION_JOB",
                "CREDIT_COOP_TRANSACTION_SYNC_JOB",
                "REAL_TIME_GL_SYNC",
                "NIURAL_INVOICE_PAYMENT_TRANSACTION_CLASSIFICATION_JOB",
                "NIURAL_INVOICE_PAYMENT_TRANSACTION_CLASSIFICATION_BULK_JOB",
                "FIREBLOCKS_VAULT_SYNC_JOB",
                "EXCHANGE_TRANSACTION_SYNC_JOB",
                "ORGANIZATION_ASSET_RESET_JOB",
                "FIREBLOCKS_TRANSACTION_SYNC_JOB",
                "ASSIGN_JOURNAL_ENTRY_TEMPLATE_JOB",
                "COST_BASIS_JOB",
                "SOURCE_ASSET_RESET_JOB",
                "EXCHANGE_RAW_INGESTION_JOB",
                "CONSOLIDATED_FEE_PROCESSING_JOB",
                "ASSET_BALANCES_BY_DAY_REPORT_JOB",
                "FETCH_BALANCES_JOB",
                "GL_IMPORT_JOB",
                "DOCUMENT_PROCESSING_JOB",
                "BULK_UNPOST_JOB",
                "BANK_STATEMENT_IMPORT_JOB",
                "BULK_CLASSIFY_TRANSACTIONS_JOB",
                "BULK_RECATEGORIZE_TRANSACTIONS_JOB",
                "BULK_UNPOST_TRANSACTIONS_JOB",
                "BULK_POST_JOURNAL_ENTRIES_JOB",
                "BULK_UNPOST_JOURNAL_ENTRIES_JOB",
                "BULK_REVERSE_JOURNAL_ENTRIES_JOB",
                "BULK_DELETE_JOURNAL_ENTRIES_JOB",
                "EMAIL_INGESTION_JOB",
                "MAPLE_TRANSACTION_IMPORT_JOB",
                "IMPORT_HEDGEY_SOURCE_JOB"
              ]
            }
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Job history retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/JobHistoryEntry"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` for the next page. `null` on the last page."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/{id}/revalue": {
      "post": {
        "operationId": "triggerRevaluation",
        "summary": "Trigger asset revaluation",
        "description": "Triggers an asynchronous mark-to-market revaluation job for the specified accounting period — unrealized gain / loss adjustments are computed against period-end prices and posted to the configured revaluation ledger accounts. Returns 202 with a `job_id`; poll `tools.jobs.get_job_status` or subscribe to `revaluation.completed` webhooks for completion. The revaluation account configuration is set via `tools.settings.update_revaluation_settings`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "asset_types": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Filter revaluation to specific asset types."
                  },
                  "ledger_account_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed ledger account IDs (`lac_`)."
                  },
                  "mark_to_market": {
                    "type": "boolean",
                    "default": false
                  }
                }
              },
              "example": {
                "asset_types": ["ETH", "BTC"],
                "mark_to_market": true
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Revaluation job enqueued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_id": {
                          "type": "string",
                          "examples": ["job_abc123"]
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": "job_abc123",
                    "message": "Revaluation job enqueued"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/balances/token": {
      "get": {
        "operationId": "getTokenBalances",
        "summary": "Get on-chain token balances",
        "description": "Returns real-time, spam-filtered on-chain token balances for the organization's connected wallets — the wallet-side feed underneath `GET /v1/treasury/cash-position`. This is a point-in-time LIVE snapshot; for balances over a date range or a specific month it is the WRONG tool — use `tools.reports.create_asset_balances_by_day_report` to build a multi-day / historical balance series (it POSTs the report; then fetch the result by id with `tools.reports.get_asset_balances_by_day_report`) (mirrors copilot onchain-analytics/index.ts:26,63). Because that create step is a MUTATING tool, it is unavailable on a read-only session — there, fall back to `tools.balances.list_balances` for GL-derived period-end BOOK balances (book, not live cash) and tell the user the live multi-day series needs a write-enabled session.\n\nBalances come from the same verified-contract-aware path the in-app copilot uses, so spoofed / spam tokens are excluded. Coverage is limited to the wallet chains supported by the configured live-balance providers; a chain without provider support is not represented as a zero balance. Covers the org's internal (treasury-owned) wallets; watch-only/external wallets are excluded. Values are USD-priced — `fiat_currency` is always `USD` (the `currency` query param does not convert). Reach for this lower-level endpoint when you only need on-chain detail (specific wallet, specific chain, specific token). For the consolidated \"how much cash do we have right now\" answer (wallets + exchanges + banks in one call with per-source degradation hints), prefer the treasury endpoint.\n\n### Performance characteristics\n\nThe call queries live balance providers rather than reading a cached snapshot. Expect:\n\n- 1–5 s latency under heavy chain load.\n- Occasional per-chain `5xx`. On failure the response is still `200` with a `degraded_sources` entry (`source: on_chain_tokens`); fall back to `GET /v1/reports/balance-sheet` for the GL-derived view.\n\n### Related endpoints\n\n- `GET /v1/balances/exchange` — exchange-side balances.\n- `GET /v1/balances` — ledger-account running totals (GL period closing balances).\n- `GET /v1/financial-accounts` — join target for per-wallet detail (returned keyed by `financial_account_id`).\n\n### Choosing among the balance & treasury tools\n\n- `tools.treasury.get_treasury_cash_position` — live cash TOTAL + per-source breakdown; the canonical \"how much cash right now\" answer.\n- `tools.balances.get_live_balances` — live per-source rows (wallets + exchanges + banks) grouped; auto-backgrounds for large orgs.\n- `tools.balances.get_token_balances` — on-chain wallet feed only (single source).\n- `tools.balances.get_exchange_balances` — exchange feed only (single source, daily snapshot).\n- `tools.balances.list_balances` — GL-derived period-end BOOK balances, NOT live cash.",
        "tags": ["Balances"],
        "parameters": [
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "chains",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Blockchain networks to include (e.g., `ethereum`, `polygon`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "include_zero",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Include tokens with zero balance."
          },
          {
            "name": "currency",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Accepted but normalized to USD — balances are USD-priced and not FX-converted; `fiat_currency` is always `USD`."
          },
          {
            "name": "historical_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "ISO date (YYYY-MM-DD) for a point-in-time balance snapshot. Must not be a future date. Canonical as-of param, matching `get_live_balances`."
          },
          {
            "name": "target_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Deprecated alias for `historical_date` (kept for back-compat). Prefer `historical_date`."
          }
        ],
        "responses": {
          "200": {
            "description": "Token balances retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "degraded_sources"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TokenBalance"
                      }
                    },
                    "degraded_sources": {
                      "type": "array",
                      "description": "Always present (empty when all sources healthy). Each entry names a source whose balances are NOT in `data` (e.g. a per-chain provider 5xx), so an empty `data` with a non-empty `degraded_sources` is a fetch failure, not a zero balance.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "source": { "type": "string" },
                          "reason": { "type": "string" },
                          "message": { "type": "string" },
                          "fallback_hint": { "type": "string" }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "financial_account_id": "fac_abc123",
                      "chain": "ethereum",
                      "token": "USDC",
                      "balance": "50000.00",
                      "fiat_value": "50000.00",
                      "fiat_currency": "USD",
                      "is_native": false,
                      "price_change_24h": 0.01,
                      "updated_at": "2024-03-15T10:00:00Z"
                    }
                  ],
                  "degraded_sources": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/balances/exchange": {
      "get": {
        "operationId": "getExchangeBalances",
        "summary": "Get exchange balances",
        "description": "Returns balances from connected CCXT exchange accounts (Binance, Coinbase, Kraken, etc.) — the exchange-side feed underneath `GET /v1/treasury/cash-position`.\n\nReach for this lower-level endpoint when you only need exchange detail. For the consolidated \"how much cash do we have right now\" answer (wallets + exchanges in one call), prefer the treasury endpoint.\n\nEach row carries asset, balance, exchange source, and fiat value. Balances are served from the **most recent daily snapshot** (USD-priced), not a per-request live exchange call — `updated_at` on each row is the snapshot time, so treat the figures as as-of that timestamp rather than real-time. A background job refreshes the snapshot daily.\n\n### Empty data vs. a failed connection\n\nAn empty `data` array can mean three different things — read `degraded_sources` to tell them apart:\n\n- **No exchanges connected, or all balances genuinely zero** → `data: []`, no `degraded_sources`. Normal, not an error.\n- **A source failed when the snapshot was built** (revoked or insufficient-scope API key, exchange geo/IP restriction, upstream outage) → the failed source is listed in `degraded_sources` (`reason: upstream_error`) with the provider, the `fac_*` id, and the underlying error. Its balances are NOT in `data`.\n- **No snapshot taken yet** → a single `degraded_sources` entry with `reason: no_snapshot`; balances appear after the daily job runs.\n\n### Related endpoints\n\n- `GET /v1/balances/token` — complementary on-chain wallet balances.\n- `GET /v1/balances` — GL-derived view of period balances (always available; use as the fallback when this endpoint reports degraded sources).\n- `POST /v1/financial-accounts/{id}/sync` — trigger a fresh trade-ingestion pull for one exchange.\n\n### Choosing among the balance & treasury tools\n\n- `tools.treasury.get_treasury_cash_position` — live cash TOTAL + per-source breakdown; the canonical \"how much cash right now\" answer.\n- `tools.balances.get_live_balances` — live per-source rows (wallets + exchanges + banks) grouped; auto-backgrounds for large orgs.\n- `tools.balances.get_token_balances` — on-chain wallet feed only (single source).\n- `tools.balances.get_exchange_balances` — exchange feed only (single source, daily snapshot).\n- `tools.balances.list_balances` — GL-derived period-end BOOK balances, NOT live cash.",
        "tags": ["Balances"],
        "parameters": [
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "currency",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Fiat currency label. Exchange balances are served from a daily USD snapshot and are NOT FX-converted, so a non-USD value is accepted but normalized to USD (the response echoes `currency: \"USD\"`); it does not 400. Default: `USD`."
          },
          {
            "name": "provider",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by exchange provider (e.g., `binance`, `coinbase`)."
          },
          {
            "name": "include_fiat_value",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Include fiat value conversion. Default: `true`."
          }
        ],
        "responses": {
          "200": {
            "description": "Exchange balances retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ExchangeBalance"
                      }
                    },
                    "degraded_sources": {
                      "type": "array",
                      "description": "Present only when one or more connected exchanges failed their live balance fetch. Each entry identifies the failed source so an empty/partial `data` is not misread as zero holdings.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "source": {
                            "type": "string",
                            "examples": ["exchange"]
                          },
                          "financial_account_id": {
                            "type": ["string", "null"]
                          },
                          "provider": {
                            "type": ["string", "null"]
                          },
                          "reason": {
                            "type": "string",
                            "examples": ["upstream_error"]
                          },
                          "message": {
                            "type": "string"
                          },
                          "fallback_hint": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "financial_account_id": "fac_abc123",
                      "legal_entity_id": "le_def456",
                      "provider": "binance",
                      "asset": "BTC",
                      "balance": "2.50000000",
                      "fiat_value": "125000.00",
                      "fiat_currency": "USD",
                      "is_native": false,
                      "updated_at": "2024-03-15T10:00:00Z"
                    }
                  ],
                  "degraded_sources": [
                    {
                      "source": "exchange",
                      "financial_account_id": "fac_989901",
                      "provider": "kraken",
                      "reason": "upstream_error",
                      "message": "Exchange balance fetch failed: kraken EAPI:Invalid key",
                      "fallback_hint": "GL-derived balances via `tools.balances.list_balances` reflect last-posted ledger balances (stale relative to live exchange state but always available)."
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/balances/live": {
      "post": {
        "operationId": "getLiveBalances",
        "x-mcp-read-only": true,
        "summary": "Combined live (or point-in-time) balances grouped per source",
        "description": "Returns balances for the org's wallets + exchanges + bank accounts, grouped one row per source with its per-token rows — the v1 home for the consolidated wallet-balances feed.\n\n### Sync vs async\n\n- A `historical_date` or `wait_for_results: true` forces a synchronous fetch and the `sources` array is returned inline (`job: null`).\n- Otherwise the call auto-detects: orgs with more than ~20 sources are backgrounded — `sources` is empty and a `job` handle is returned (202), pollable via `GET /v1/jobs/{id}/status`.\n\n### Partial results\n\nThe response is `200` with whatever completed even when a source fails or the fetch times out — failed sources are summarized in `degraded_sources` (and carry their own `error` on the source row).\n\n### Exchange balances come from the daily snapshot\n\nExchange rows are served from the daily exchange-balance snapshot, never fetched live on the request path (exchange API keys are IP-whitelisted to one region's egress IP, and this endpoint is served from multiple regions). Each exchange row carries the snapshot's timestamp in `historical_date` so staleness is visible. When no snapshot is available for the requested window the exchange leg is omitted from the total and flagged with a `degraded_sources` entry (`source: exchange`, `reason: no_snapshot`); on a live request a refresh is queued so the next call includes them.\n\n### Related endpoints\n\n- `GET /v1/balances/token` / `GET /v1/balances/exchange` — single-source feeds.\n- `GET /v1/treasury/cash-position` — a single consolidated total (flat, no grouping).\n\n### Historical (point-in-time)\n\n`historical_date` historizes on-chain balances, and exchange balances are read from THAT day's snapshot only (a miss is reported as `reason: no_snapshot` rather than substituting a current figure). Bank (Plaid) balances are current-only (no point-in-time API) and are EXCLUDED from a historical response — flagged with a `degraded_sources` entry (`source: bank_accounts`, `reason: excluded`). A historical total therefore covers on-chain + exchange only. `currency` is accepted but normalized to USD (no FX). Future `historical_date` values are rejected.\n\n### Choosing among the balance & treasury tools\n\n- `tools.treasury.get_treasury_cash_position` — live cash TOTAL + per-source breakdown; the canonical \"how much cash right now\" answer.\n- `tools.balances.get_live_balances` — live per-source rows (wallets + exchanges + banks) grouped; auto-backgrounds for large orgs.\n- `tools.balances.get_token_balances` — on-chain wallet feed only (single source).\n- `tools.balances.get_exchange_balances` — exchange feed only (single source, daily snapshot).\n- `tools.balances.list_balances` — GL-derived period-end BOOK balances, NOT live cash.\n\n### Empty is not zero\n\nAn EMPTY sources array together with a non-empty degraded_sources is a FETCH FAILURE, not a zero balance: do NOT report $0. Retry or narrow the request: fewer chains/wallets, or a single legal entity.",
        "tags": ["Balances"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "financial_account_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed internal financial-account ids (`fac_`) to scope to (omit for all org-owned sources)."
                  },
                  "legal_entity_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed legal-entity ids (`le_`)."
                  },
                  "chains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "addresses": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Org-owned wallet addresses to scope to. Counterparty/external addresses are not tracked balances."
                  },
                  "historical_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Point-in-time balances (YYYY-MM-DD or ISO-8601 timestamp). ANY value is read as the END of its UTC day: only the date part is kept, so a full timestamp does NOT give intra-day precision - its time component is discarded. Forces a synchronous fetch."
                  },
                  "include_bank_accounts": {
                    "type": "boolean",
                    "description": "Whether bank accounts are included alongside wallets and exchanges. DEFAULTS TO TRUE: omitting this INCLUDES banks. Pass false for a wallet or exchange-only request. A `historical_date` request ignores this flag and ALWAYS excludes bank accounts, because there is no point-in-time bank balance source; the response reports that with a `degraded_sources[]` entry (`source: bank_accounts`, `reason: excluded`), so a historical total covers on-chain and exchange only."
                  },
                  "show_all_exchanges": {
                    "type": "boolean",
                    "description": "Whether exchange accounts are included alongside wallets and banks. DEFAULTS TO TRUE: omitting this INCLUDES exchanges. Pass false to exclude exchange balances (e.g. the user asked only about wallets or banks)."
                  },
                  "wait_for_results": {
                    "type": "boolean",
                    "description": "Force a synchronous fetch so the rows are returned inline. Set true when you need the data for a follow-up action: sending an email, comparing values, or answering a specific question about amounts. When true, the tool waits for results synchronously instead of returning a background job."
                  },
                  "currency": {
                    "type": "string",
                    "default": "USD"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Balances fetched synchronously — `sources` inline, `job` null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "sources": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/LiveSourceBalance"
                          }
                        },
                        "currency": {
                          "type": "string"
                        },
                        "historical_date": {
                          "type": ["string", "null"]
                        },
                        "degraded_sources": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "source": {
                                "type": "string"
                              },
                              "financial_account_id": {
                                "type": ["string", "null"]
                              },
                              "reason": {
                                "type": "string"
                              },
                              "message": {
                                "type": "string"
                              },
                              "fallback_hint": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "job": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/AssetBalanceByDayJob"
                            },
                            {
                              "type": "null"
                            }
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Large org — fetch backgrounded. `sources` empty, `job` handle returned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "sources": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/LiveSourceBalance"
                          }
                        },
                        "currency": {
                          "type": "string"
                        },
                        "historical_date": {
                          "type": ["string", "null"]
                        },
                        "degraded_sources": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        },
                        "job": {
                          "$ref": "#/components/schemas/AssetBalanceByDayJob"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/treasury/cash-position": {
      "get": {
        "operationId": "getTreasuryCashPosition",
        "summary": "Consolidated live cash position",
        "description": "Returns the org's live treasury position as a single number plus the per-source breakdown — the canonical answer for \"how much cash do we have right now?\"\n\nOn-chain wallet balances AND connected bank accounts come from one spam-filtered, verified-contract-aware fetch (the same path the in-app copilot's live-balances view uses) — so spoofed / spam tokens are excluded from the total and every wallet chain the org holds (Ethereum, L2s, Solana, Bitcoin, Cosmos, etc.) is covered, not just a subset. Bank balances appear as real `by_source` rows (`source_class: bank`, `provider` = institution name), not a gap. Exchange cash comes from the daily USD-priced snapshot (not a per-request live call — `data_as_of` reports the resulting staleness). Exchange balances are always USD-priced; the `currency` param is accepted for backward-compat but normalized to USD (no FX), so exchanges are never dropped for currency. `reason: not_supported_yet` applies to non-USD BANK balances; `reason: no_snapshot` appears when no exchange snapshot has been taken yet.\n\n### When NOT to use this\n\nFor period-end / GL-derived balances use `GET /v1/reports/balance-sheet` instead — that's a point-in-time book balance, not live.\n\n### Partial responses\n\nThe response is `200` with partial data when a sub-source 5xxs (for example an Ethereum RPC outage) or a Plaid bank fetch fails:\n\n- The failed source appears in `degraded_sources` (`on_chain_tokens`, `exchanges`, or `bank_accounts`) with a fallback hint.\n- `live_total_fiat` excludes it, so the caller knows the number is a floor.\n\n### Sorting and filters\n\n- Sorted by `fiat_value` descending — largest position first.\n- `?include_zero=false` (default) hides dust and empty wallets. Pass `true` for a reconciliation view.\n\n### As-of / historical\n\nThis endpoint accepts `historical_date` (YYYY-MM-DD) for a point-in-time position: on-chain balances are historized; bank balances are excluded (Plaid has no point-in-time API, flagged `reason: not_supported_yet`); exchange cash comes ONLY from that day's snapshot, and a missing snapshot is surfaced as a gap (`reason: no_snapshot`) rather than a live figure stamped as historical. `get_live_balances` remains the per-source grouped alternative. Each `by_source` row carries `alias` (the wallet/account name, e.g. \"Entendre Finance Safe\") and `entity_name` — use those to label a source rather than `source_class`. `currency` is normalized to USD (no FX).\n\n### Choosing among the balance & treasury tools\n\n- `tools.treasury.get_treasury_cash_position` — live cash TOTAL + per-source breakdown; the canonical \"how much cash right now\" answer.\n- `tools.balances.get_live_balances` — live per-source rows (wallets + exchanges + banks) grouped; auto-backgrounds for large orgs.\n- `tools.balances.get_token_balances` — on-chain wallet feed only (single source).\n- `tools.balances.get_exchange_balances` — exchange feed only (single source, daily snapshot).\n- `tools.balances.list_balances` — GL-derived period-end BOOK balances, NOT live cash.",
        "tags": ["Treasury"],
        "parameters": [
          {
            "name": "currency",
            "in": "query",
            "required": false,
            "description": "Accepted for backward-compat but IGNORED: all figures are normalized to USD (no FX conversion). The response always echoes `currency: \"USD\"`.",
            "schema": {
              "type": "string",
              "example": "USD"
            }
          },
          {
            "name": "include_zero",
            "in": "query",
            "required": false,
            "description": "When `true`, include $0 balances. Default `false` (suppress dust + zero balances for readability).",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "historical_date",
            "in": "query",
            "required": false,
            "description": "Point-in-time position (YYYY-MM-DD). Omit for the live position. When set: on-chain balances are historized to that date; bank (Plaid) balances are EXCLUDED (no point-in-time API) and flagged in `degraded_sources` (`source: bank_accounts`, `reason: not_supported_yet`); exchange cash is read ONLY from that day's snapshot and, if none exists, is surfaced as a gap (`source: exchanges`, `reason: no_snapshot`) rather than substituted with a live figure. Must not be a future date. `currency` is still normalized to USD.",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2024-12-30"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Live cash position (200 even when a sub-source is degraded — check `degraded_sources`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "requested_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "Wall-clock time the request was processed. NOT a freshness signal — see `data_as_of`."
                        },
                        "data_as_of": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "Oldest `updated_at` across `by_source` rows — the floor freshness signal. `null` when there are zero rows."
                        },
                        "historical_date": {
                          "type": "string",
                          "nullable": true,
                          "description": "Echo of the requested point-in-time date (YYYY-MM-DD), or `null` for a live position. When set, the figures are as-of this date (banks excluded; a missing exchange snapshot for the date appears in `degraded_sources`)."
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "live_total_fiat": {
                          "type": "string",
                          "example": "790280.42"
                        },
                        "by_source": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "source_class": {
                                "type": "string",
                                "enum": ["crypto", "exchange", "bank"]
                              },
                              "financial_account_id": {
                                "type": "string",
                                "nullable": true
                              },
                              "chain": {
                                "type": "string",
                                "nullable": true
                              },
                              "provider": {
                                "type": "string",
                                "nullable": true
                              },
                              "asset": {
                                "type": "string"
                              },
                              "alias": {
                                "type": "string",
                                "nullable": true,
                                "description": "Human-readable wallet / account name (e.g. \"Entendre Finance Safe\"); null when unnamed."
                              },
                              "entity_name": {
                                "type": "string",
                                "nullable": true,
                                "description": "Legal-entity name this source belongs to; null when unset."
                              },
                              "balance": {
                                "type": "string"
                              },
                              "fiat_value": {
                                "type": "string"
                              },
                              "fiat_currency": {
                                "type": "string"
                              },
                              "is_native": {
                                "type": "boolean"
                              },
                              "updated_at": {
                                "type": "string",
                                "format": "date-time"
                              }
                            }
                          }
                        },
                        "degraded_sources": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "source": {
                                "type": "string",
                                "enum": ["on_chain_tokens", "exchanges", "bank_accounts"]
                              },
                              "reason": {
                                "type": "string",
                                "enum": ["upstream_error", "not_supported_yet", "no_snapshot"]
                              },
                              "message": {
                                "type": "string"
                              },
                              "fallback_hint": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/audit-events": {
      "get": {
        "operationId": "listAuditEvents",
        "summary": "List audit events",
        "description": "Returns audit-trail rows describing who-did-what-when within the organization (key rotations, API operations, period closes).\n\nUse this to answer questions like \"who closed the May 2026 period?\" or \"show me all GL disconnects in the past 30 days.\"\n\n### Filters\n\n- `?action=` — case-insensitive substring match on the underlying `operationPath` (the raw request path, hyphenated — e.g. `api-keys`). Note the `action` value returned on each row is a normalized underscored token (e.g. `api_keys.rotate`), so filter on the hyphenated path form (`api-keys`), not the underscored token shown in results.\n- `?start=` / `?end=` — ISO timestamp window on `createdAt`.\n- `?user_id=` — filter by actor.\n\nSorted newest first; keyset paginated.\n\n### Coverage caveat\n\nCurrently sourced from the internal `AuditRecord` collection. Not every business event writes to this collection yet — period closes and GL disconnects are tracked as follow-ups.\n\nFor period-close attribution specifically, the `closed_by` field on `GET /v1/accounting-periods/{id}` is the canonical source.",
        "tags": ["AuditEvents"],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            },
            "description": "Case-insensitive substring match on the underlying operationPath."
          },
          {
            "name": "start",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Lower bound on `created_at` (ISO 8601)."
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Upper bound on `created_at` (ISO 8601)."
          },
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed user id (`usr_*`) — filter by actor."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated audit-event list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "action": {
                            "type": "string"
                          },
                          "actor_user_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "actor_type": {
                            "type": "string",
                            "enum": ["user", "system", "api"]
                          },
                          "target_type": {
                            "type": "string",
                            "nullable": true
                          },
                          "target_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/ledger-accounts/{id}/archive": {
      "post": {
        "operationId": "archiveLedgerAccount",
        "summary": "Archive a ledger account",
        "description": "Marks a ledger account as archived — sets `is_archived: true` and stamps `archived_at` with the current timestamp. Archived accounts stay referenceable by historical journal entries (the chart-of-accounts row is preserved) but are filtered out of the default `list_ledger_accounts` view so they don't clutter dropdowns or copilot search. Use this for QuickBooks-default accounts the org never adopted, or for accounts deprecated by a CoA cleanup. Distinct from `delete_ledger_account` (soft delete — blocks references): archive preserves references, delete blocks them. Idempotent — calling on an already-archived account returns 200 with the current state. To restore: `tools.ledger_accounts.unarchive_ledger_account`.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed ledger account id (`lac_*`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Ledger account archived (or already archived — idempotent).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedgerAccount"
                }
              }
            }
          },
          "404": {
            "description": "Ledger account not found."
          }
        }
      }
    },
    "/ledger-accounts/{id}/unarchive": {
      "post": {
        "operationId": "unarchiveLedgerAccount",
        "summary": "Unarchive a ledger account",
        "description": "Restores an archived ledger account — clears `is_archived` and `archived_at` so the account reappears in the default `list_ledger_accounts` view. Idempotent. Inverse of `tools.ledger_accounts.archive_ledger_account`.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed ledger account id (`lac_*`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Ledger account unarchived (or already active — idempotent).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedgerAccount"
                }
              }
            }
          },
          "404": {
            "description": "Ledger account not found."
          }
        }
      }
    },
    "/legal-entities/{id}/archive": {
      "post": {
        "operationId": "archiveLegalEntity",
        "summary": "Archive a legal entity",
        "description": "Marks a legal entity as archived — sets `status: 'ARCHIVED'`. Archived legal entities stay referenceable by historical journal entries and accounting periods, but are filtered out of the default `list_legal_entities` view. Use this for retired subsidiaries, post-merger entities, or test entities created during onboarding. Distinct from `delete_legal_entity` (soft delete — blocks references): archive preserves references, delete blocks them. Idempotent — calling on an already-archived entity returns 200 with the current state. To restore: `tools.legal_entities.unarchive_legal_entity`.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed legal entity id (`le_*`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Legal entity archived (or already archived — idempotent).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LegalEntity"
                }
              }
            }
          },
          "404": {
            "description": "Legal entity not found."
          }
        }
      }
    },
    "/legal-entities/{id}/unarchive": {
      "post": {
        "operationId": "unarchiveLegalEntity",
        "summary": "Unarchive a legal entity",
        "description": "Restores an archived legal entity — sets `status: 'ACTIVE'` so it reappears in the default `list_legal_entities` view. Idempotent. Inverse of `tools.legal_entities.archive_legal_entity`.",
        "tags": ["Legal Entities"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed legal entity id (`le_*`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Legal entity unarchived (or already active — idempotent).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LegalEntity"
                }
              }
            }
          },
          "404": {
            "description": "Legal entity not found."
          }
        }
      }
    },
    "/assets": {
      "get": {
        "operationId": "listAssets",
        "summary": "List assets",
        "description": "Returns a paginated list of digital-asset lot records with filtering (by asset type, legal entity, ledger account, chain, and date received) and sorting. Each row carries quantity, cost basis, and lot acquisition metadata. For a single lot by ID use `tools.assets.get_asset`.",
        "tags": ["Assets"],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["date_received", "asset_type", "quantity", "cost_basis", "created_at"]
            },
            "description": "Sort field."
          },
          {
            "name": "sort_direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["asc", "desc"],
              "default": "desc"
            },
            "description": "Sort direction."
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "ledger_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed ledger account IDs (`lac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter by asset type (e.g., `ETH`, `BTC`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "chains",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter by blockchain chain.",
            "style": "form",
            "explode": true
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter assets received on or after this date (ISO 8601)."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter assets received on or before this date (ISO 8601)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of assets.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Asset"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` if more pages exist."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Pass as `cursor` query parameter for the next page. `null` on the last page."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createAsset",
        "summary": "Create a manual asset",
        "description": "Creates a manual asset lot record and adjusts the corresponding ledger account balance. Used for off-chain or manually tracked positions.",
        "tags": ["Assets"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "asset_type",
                  "quantity",
                  "cost_basis",
                  "legal_entity_id",
                  "ledger_account_id",
                  "source_id"
                ],
                "properties": {
                  "asset_type": {
                    "type": "string",
                    "examples": ["ETH"]
                  },
                  "quantity": {
                    "type": "string",
                    "description": "Positive decimal string (Decimal128-safe), e.g. \"10.5\". Sent as a string to preserve precision — a JSON number is rejected."
                  },
                  "cost_basis": {
                    "type": "string",
                    "description": "Decimal string (Decimal128-safe), e.g. \"15750.00\". Sent as a string to preserve precision — a JSON number is rejected."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed legal entity ID (`le_`)."
                  },
                  "ledger_account_id": {
                    "type": "string",
                    "description": "Prefixed ledger account ID (`lac_`)."
                  },
                  "date_received": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "source_id": {
                    "type": "string",
                    "description": "Prefixed financial account ID (`fac_`)."
                  },
                  "currency": {
                    "type": "string",
                    "description": "Defaults to legal entity base currency."
                  },
                  "chain": {
                    "type": "string"
                  }
                }
              },
              "example": {
                "asset_type": "ETH",
                "quantity": "10.5",
                "cost_basis": "15750.00",
                "legal_entity_id": "le_abc123",
                "ledger_account_id": "lac_def456",
                "source_id": "fac_789xyz",
                "date_received": "2024-03-01T00:00:00Z"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Asset created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Asset"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/assets/metrics": {
      "get": {
        "operationId": "getAssetMetrics",
        "summary": "Get asset metrics",
        "description": "Returns aggregate asset metrics across the organization — total cost basis, market value, unrealized gain / loss, and a breakdown by asset type — computed from the COST-BASIS SUBLEDGER (tax lots), NOT from live wallet holdings. It can diverge materially from what the wallets actually hold: unreconciled lots or spam-token artifacts inflate cost basis, and market value can mirror cost basis where a lot has no live price (so unrealized reads ~0). For \"what's my crypto worth / current holdings right now\", use the LIVE feed (`tools.balances.get_live_balances` / `tools.treasury.get_treasury_cash_position`) — the same source the in-app copilot uses — and treat a large gap between the two as a data issue to SURFACE, not a number to report. Use this tool only for tax-lot cost-basis questions, and cross-check its totals against the live feed. For per-lot detail use `tools.assets.list_assets`.",
        "tags": ["Assets"],
        "parameters": [
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Filter by asset type.",
            "style": "form",
            "explode": true
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Start date filter (ISO 8601)."
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "End date filter (ISO 8601)."
          }
        ],
        "responses": {
          "200": {
            "description": "Asset metrics retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AssetMetrics"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/assets/types": {
      "get": {
        "operationId": "getAssetTypes",
        "summary": "List asset types",
        "description": "Returns a sorted list of the distinct asset types found across the organization's non-spam TRANSACTIONS. Useful for populating filter UIs. NOTE: this enumerates types seen in real (non-spam) transaction activity, not raw cost-basis lots — an asset that appears only as a spam/airdrop lot (e.g. a phantom PUMP/TON position) or has no non-spam transaction will not be listed. It is not an exhaustive index of every lot in the subledger.",
        "tags": ["Assets"],
        "responses": {
          "200": {
            "description": "Asset types retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                },
                "example": {
                  "data": ["BTC", "ETH", "SOL", "USDC"]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/assets/{id}": {
      "get": {
        "operationId": "getAsset",
        "summary": "Get an asset",
        "description": "Returns the full record for a single asset lot by prefixed `ast_` ID — asset type, quantity, remaining quantity, cost basis, currency, acquisition metadata (chain, date received, source), and timestamps. Use this for one-off lookups when the ID is known; for browsing or filtered queries use `tools.assets.list_assets` instead. The response is org-scoped (404 if the ID belongs to a different organization).",
        "tags": ["Assets"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Asset found.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Asset"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateAsset",
        "summary": "Update an asset",
        "description": "Updates mutable fields on a manual asset lot — only manually created lots are editable; lots sourced from exchange / wallet ingestion are read-only. Quantity and cost-basis changes trigger corresponding balance adjustments inside one MongoDB transaction so the ledger stays consistent. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Assets"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ledger_account_id": {
                    "type": "string"
                  },
                  "cost_basis": {
                    "type": "string",
                    "description": "Decimal string (Decimal128-safe), e.g. \"18750.00\". Sent as a string to preserve precision — a JSON number is rejected."
                  },
                  "quantity": {
                    "type": "string",
                    "description": "Positive decimal string (Decimal128-safe), e.g. \"12.5\". Sent as a string to preserve precision — a JSON number is rejected."
                  },
                  "legal_entity_id": {
                    "type": "string"
                  }
                }
              },
              "example": {
                "quantity": "12.5",
                "cost_basis": "18750.00"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Asset updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Asset"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteAsset",
        "summary": "Delete an asset",
        "description": "Deletes a single asset lot record as a DESTRUCTIVE soft-delete and reverses the corresponding balance adjustment inside one MongoDB transaction so the asset count and balance stay consistent. Soft-deleted lots are excluded from list / get and from cost-basis calculations going forward. Recovery requires operator intervention. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Assets"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "204": {
            "description": "Asset deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/balance-sheet": {
      "get": {
        "operationId": "getBalanceSheetReport",
        "summary": "Balance sheet",
        "description": "Returns CUMULATIVE balance per Asset, Liability, and Equity account as of the end of the specified period. Opening balance carries forward from prior periods + activity through the period end.\n\nUse for \"point-in-time net worth\" questions and as a fallback for cash position when live treasury endpoints are degraded.\n\nEach row nests a `credit_debit` object — opening / closing / current balance, debits, and credits as `{ value }` decimal-string objects, plus the period's UTC start date and the contributing `le_`-prefixed legal entity IDs — and a `tag_balance` (populated when `tag_id` is passed). `ledger_accounts` carries the full account projection (`sequence_number`, `parent_account_id`, `is_postable`, `normal_balance`) for client-side ordering and hierarchy.\n\n### Opening-balance carry-forward requires CLOSED prior periods\n\nCarry-forward happens only when the preceding accounting period is **closed** (closing the period seeds the next period's `opening_balance` from its `closing_balance`). For an organization that does not close its periods, `opening_balance` is `0` and `closing_balance` reflects only the in-period debit/credit movement — NOT the cumulative position. If you see `0` openings on a long-operating entity, the periods have not been closed; close them (or treat the figures as period movement, not point-in-time balances).\n\n### When NOT to use this\n\n- **As-of is the GL period-end snapshot, NOT live treasury.** For \"what's in our wallets / exchanges right now\", use `GET /v1/balances/token` + `GET /v1/balances/exchange`.\n- **This is not a P&L.** For income / expense activity within a single period, use `GET /v1/reports/income-statement`.\n- For ALL account types (including Income and Expense) in cumulative form, use `GET /v1/reports/trial-balance`.\n\n### Connected-GL orgs — prefer the external GL\n\nThis reads Entendre's OWN ledger. If the org has a connected accounting system (QuickBooks / Xero / NetSuite), that external GL is the reconciled book of record — for balance-sheet / total-assets / A/R answers prefer `raw_glread` `path:\"/reports/BalanceSheet\"`. Treat a non-footing, negative-asset, or all-zero-opening internal result as unreconciled and defer to the connected GL.\n\n### Data-quality signal\n\nThe response includes a `data_quality` object with `negative_asset_periods[]` (each `{ period, total }`) and `warnings[]`. A negative asset total in any period is physically impossible for a going concern — treat the balance sheet as corrupted/unreconciled and defer to the connected GL.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed accounting period ID (`ap_`). Omitting BOTH this and accounting_period_ids does NOT apply a sane default: with no period filter the report returns EVERY accounting period in the org history as columns. Resolve the target period FIRST via list_accounting_periods_lite and pass it explicitly. When the user gives no period, the in-app copilot defaults to the last 3 month-starts."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Multiple prefixed accounting period IDs. Omitting BOTH this and accounting_period_id does NOT apply a sane default: with no period filter the id list resolves to no date filter and the report returns EVERY accounting period in the org history as columns. Resolve the specific periods FIRST via list_accounting_periods_lite and pass them explicitly. When the user gives no period, the in-app copilot defaults to the last 3 month-starts.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Multiple prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "tag_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed tag ID (`tag_`) for segment filtering. ONLY include if the user explicitly asks for a tag filter; use list_tags to look up the id first."
          },
          {
            "name": "include_zero_rows",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": true
            },
            "x-mcp-wrapper-default": false,
            "description": "When `false`, accounts whose balance is zero in every requested period are omitted from both `ledger_accounts` and `balances`; when `true`, the complete chart of accounts is returned. The HTTP API default is `true` (the full hierarchy the shared balance-sheet table needs). MCP report tools default this to `false` (slim) since a long-operating org's report is mostly zero rows and the full matrix is wasted LLM context — pass `true` only when you need every chart-of-accounts row."
          },
          {
            "name": "verbosity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["faithful", "compact"],
              "default": "faithful"
            },
            "x-mcp-wrapper-default": "compact",
            "description": "Per-row detail level. `faithful` (the HTTP API default) returns the full documented 200 response schema: the full ledger-account projection and nested `{ value }` decimal objects the shared balance-sheet table needs. `compact` returns a DIFFERENT shape — same accounts and figures, but it keeps only `id`, `name`, `type`, `normal_balance`, `sequence_number`, `parent_account_id`; flattens each decimal to a bare string; drops the redundant per-row `accounting_period_start_date_utc`; and adds a top-level `meta` object (currency, full requested-period list, `account_count` vs `total_account_count`, `zero_rows_omitted`, and the omitted-field lists) so nothing is silently lost. The presence of `meta` is the discriminator: a response carrying `meta` is the compact shape; its absence is the faithful shape above. No accounts or figures are dropped in either. MCP report tools default to `compact` to save LLM context; pass `faithful` only when you need the complete schema (e.g. rendering the full table)."
          }
        ],
        "responses": {
          "200": {
            "description": "Balance sheet retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "ledger_accounts": {
                          "type": "array",
                          "description": "Full chart-of-accounts projection for every account on the report — same shape as `GET /v1/ledger-accounts` (includes `sequence_number` for ordering, `parent_account_id` for hierarchy, `is_postable`, `normal_balance`).",
                          "items": {
                            "$ref": "#/components/schemas/LedgerAccount"
                          }
                        },
                        "balances": {
                          "type": "object",
                          "description": "Keyed by prefixed ledger account ID → period name → balance row.",
                          "additionalProperties": {
                            "type": "object",
                            "additionalProperties": {
                              "$ref": "#/components/schemas/FinancialReportRow"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/income-statement": {
      "get": {
        "operationId": "getIncomeStatementReport",
        "summary": "Income statement",
        "description": "Returns the internal reporting engine's period-labelled income and expense balances, grouped by ledger account. Only Income and Expense accounts are surfaced here.\n\nUse this for an internal accrual P&L presentation. State that basis; do not describe the result as reconciled or as tied to an external GL unless that comparison was retrieved for the identical scope.\n\nEach row nests a `credit_debit` object — opening / closing / current balance, debits, and credits as `{ value }` decimal-string objects, plus the period's UTC start date and the contributing `le_`-prefixed legal entity IDs — and a `tag_balance` (populated when `tag_id` is passed). `ledger_accounts` carries the full account projection (`sequence_number`, `parent_account_id`, `is_postable`, `normal_balance`) for client-side ordering and hierarchy.\n\n### Report-to-journal-entry tracing\n\nThis report is an aggregate balance presentation, not a journal-entry lineage response. When a user asks whether a reported account balance has backing entries, resolve the exact `lac_` IDs from the returned rows, then query `GET /v1/journal-entries` using those IDs and the same accounting-date bounds. Use `limit=100`, follow `next_cursor` until `has_more` is false, and request `include_count=true` when a complete count matters. If the report and entry listing disagree, report that discrepancy as observed; do not claim a cause or that either source ties out. A zero report row is not proof that the account has no historical entries, and an empty dated JE result is not proof that it never received postings — run a separately labelled all-history account query before making a latest-posting claim.\n\n### Pick a different endpoint when…\n\n- You want cumulative balance carried forward across periods → `GET /v1/reports/trial-balance` (all account types).\n- You want point-in-time net worth → `GET /v1/reports/balance-sheet` (assets / liabilities).\n\n### Basis warning\n\n`financial_insights.*` defaults to `source=transactions` over MCP (cash-basis) and can undercount accrual expenses. For net income / profit / expenses, prefer this internal accrual report or retrieve the connected GL's P&L for an explicitly comparable scope. Say which basis you used.\n\nThis accrual figure is only meaningful when the period's books are COMPLETE. A ~$0 or understated result on an OPEN period is usually incomplete-books noise, not zero activity — though an open period that is fully accounted is still correct on accrual (open ≠ incomplete). Before trusting it, verify completeness for the report's period across EVERY legal entity in the answer's scope — a consolidated view is complete only if ALL its entities are — via `reports.get_accounting_percentage`, which takes a single `accounting_period_id` (`ap_`-prefixed — resolve the period first) and reports PERIOD-level coverage %; it does NOT accept a `legal_entity_id` or a date range, so it is not a per-entity call. For per-entity granularity, use the count of `transactions.list_transactions({ start_date, end_date, legal_entity_id, has_posted_journal_entry: false, include_count: true })` per entity (omit `legal_entity_id` to count all at once). If any in-scope entity has low coverage or unaccounted transactions — or the accrual report and the connected GL P&L come back empty — do NOT report net income as \"unavailable\": fall back to `tools.financial_insights.get_financial_insight_profit` (cash-basis), STATE that basis, and name the unaccounted count. An empty accrual report is not proof of zero activity. This internal report is NOT a direct QuickBooks read; for a direct provider-native GL profit-and-loss use `tools.gl.raw_glread` with path '/reports/ProfitAndLoss', the income-statement counterpart to the balance sheet `tools.gl.raw_glread` path '/reports/BalanceSheet'.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed accounting period ID (`ap_`). Omitting BOTH this and accounting_period_ids does NOT apply a sane default: with no period filter the report returns EVERY accounting period in the org history as columns. Resolve the target period FIRST via list_accounting_periods_lite and pass it explicitly. When the user gives no period, the in-app copilot defaults to the last 3 month-starts."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Multiple prefixed accounting period IDs. Omitting BOTH this and accounting_period_id does NOT apply a sane default: with no period filter the id list resolves to no date filter and the report returns EVERY accounting period in the org history as columns. Resolve the specific periods FIRST via list_accounting_periods_lite and pass them explicitly. When the user gives no period, the in-app copilot defaults to the last 3 month-starts.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Multiple prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "tag_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed tag ID (`tag_`) for segment filtering. ONLY include if the user explicitly asks for a tag filter; use list_tags to look up the id first."
          },
          {
            "name": "include_zero_rows",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": true
            },
            "x-mcp-wrapper-default": false,
            "description": "When `false`, accounts with zero movement in every requested period are omitted from both `ledger_accounts` and `balances`; when `true`, the complete chart of accounts is returned. The HTTP API default is `true` (the full hierarchy the shared balance-sheet table needs). MCP report tools default this to `false` (slim) since a long-operating org's report is mostly zero rows and the full matrix is wasted LLM context — pass `true` only when you need every chart-of-accounts row."
          },
          {
            "name": "verbosity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["faithful", "compact"],
              "default": "faithful"
            },
            "x-mcp-wrapper-default": "compact",
            "description": "Per-row detail level. `faithful` (the HTTP API default) returns the full documented 200 response schema: the full ledger-account projection and nested `{ value }` decimal objects the shared balance-sheet table needs. `compact` returns a DIFFERENT shape — same accounts and figures, but it keeps only `id`, `name`, `type`, `normal_balance`, `sequence_number`, `parent_account_id`; flattens each decimal to a bare string; drops the redundant per-row `accounting_period_start_date_utc`; and adds a top-level `meta` object (currency, full requested-period list, `account_count` vs `total_account_count`, `zero_rows_omitted`, and the omitted-field lists) so nothing is silently lost. The presence of `meta` is the discriminator: a response carrying `meta` is the compact shape; its absence is the faithful shape above. No accounts or figures are dropped in either. MCP report tools default to `compact` to save LLM context; pass `faithful` only when you need the complete schema (e.g. rendering the full table)."
          }
        ],
        "responses": {
          "200": {
            "description": "Income statement retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "ledger_accounts": {
                          "type": "array",
                          "description": "Full chart-of-accounts projection for every account on the report — same shape as `GET /v1/ledger-accounts` (includes `sequence_number` for ordering, `parent_account_id` for hierarchy, `is_postable`, `normal_balance`).",
                          "items": {
                            "$ref": "#/components/schemas/LedgerAccount"
                          }
                        },
                        "balances": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "object",
                            "additionalProperties": {
                              "$ref": "#/components/schemas/FinancialReportRow"
                            }
                          }
                        },
                        "data_quality": {
                          "type": "object",
                          "description": "Data-quality signals, shared shape across the reports family. The P&L has no impossible-value invariant of its own, so `warnings` is always empty here.",
                          "properties": {
                            "warnings": {
                              "type": "array",
                              "items": { "type": "string" }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/trial-balance": {
      "get": {
        "operationId": "getTrialBalanceReport",
        "summary": "Trial balance",
        "description": "Returns CUMULATIVE balance per account (Asset, Liability, Equity, Income, Expense) for the required `start_date` through `end_date` period — opening balance + all activity through the ending accounting period, NOT period-only movement. Both date boundaries are required. When the user requests a calendar period, expand it to the complete inclusive UTC window: for example, July 2026 means `2026-07-01T00:00:00.000Z` through `2026-07-31T23:59:59.999Z`. The API resolves that date window to the same accounting-period keys used by the app; a window with no matching accounting periods returns 400 instead of falling back to lifetime balances.\n\n**This is not a P&L.** Income and Expense accounts accumulate across all open periods until the period is closed, so a trial balance reading on Revenue / Expenses can span more than the selected period's movement.\n\n### Pick a different endpoint when…\n\n- You want period-only income / expense activity (P&L questions) → `GET /v1/reports/income-statement`.\n- You want only Asset / Liability / Equity (point-in-time net worth without income / expense rollup) → `GET /v1/reports/balance-sheet`.\n\n### Data-quality signal\n\nThe response includes a `data_quality` object: `is_balanced` (total debits == total credits), `total_debits`, `total_credits`, and `warnings[]`. When `warnings` is non-empty, do NOT present the balances as reconciled. Two conditions raise a warning: (1) the trial balance does NOT foot (`is_balanced: false`) — the ledger is internally inconsistent; and (2) the trial balance is EMPTY — no ledger account had any activity, or the org has no ledger accounts configured, so `is_balanced: true` is only a vacuous 0 == 0. An EMPTY trial balance is NOT proof the org had no activity: posted journal entries can exist but be unmapped to a chart of accounts, and the authoritative books may live in a connected GL — check for a connected GL (`integrations.list_integrations`) and, if one exists, pull the trial balance from it instead of presenting the empty internal result.\n\n### Account projection & footing reconciliation\n\n`ledger_accounts` carries the full account projection (same shape as `GET /v1/ledger-accounts`: `sequence_number`, `parent_account_id`, `is_postable`, `normal_balance`). The `data_quality` totals foot over LEAF accounts only, but `balances[]` also contains parent roll-up rows. To reconcile a manual sum of `balances[]` against `total_debits` / `total_credits`, sum ONLY rows whose `ledger_account_id` maps to a `ledger_accounts` entry with `is_postable: true` — including the parent rows double-counts and will not reconcile.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Multiple prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "tag_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed tag ID (`tag_`) for segment filtering."
          },
          {
            "name": "start_date",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Required inclusive period start (ISO 8601)."
          },
          {
            "name": "end_date",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Required inclusive period end (ISO 8601)."
          }
        ],
        "responses": {
          "200": {
            "description": "Trial balance retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "ledger_accounts": {
                          "type": "array",
                          "description": "Full chart-of-accounts projection for every account on the report — same shape as `GET /v1/ledger-accounts` (includes `sequence_number` for ordering, `parent_account_id` for hierarchy, `is_postable`, `normal_balance`). `is_postable: true` marks a leaf (postable) account; `false` marks a parent roll-up account.",
                          "items": {
                            "$ref": "#/components/schemas/LedgerAccount"
                          }
                        },
                        "balances": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "ledger_account_id": {
                                "type": "string"
                              },
                              "opening_balance": {
                                "type": "string"
                              },
                              "closing_balance": {
                                "type": "string"
                              },
                              "current_balance": {
                                "type": "string"
                              },
                              "debits": {
                                "type": "string"
                              },
                              "credits": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "data_quality": {
                          "type": "object",
                          "description": "Tool-layer data-quality signal for the internal-ledger trial balance. When `warnings` is non-empty, do NOT present the balances as reconciled. Prefer the booleans (`is_balanced`, `is_empty`) over substring-matching the warning prose.",
                          "properties": {
                            "is_balanced": {
                              "type": "boolean",
                              "description": "Total debits == total credits across LEAF accounts (within a half-cent tolerance). Necessary but NOT sufficient for a clean answer — see `is_empty` and the operation description."
                            },
                            "is_empty": {
                              "type": "boolean",
                              "description": "The report carries no balances at all: either there are no ledger accounts, or every leaf account is flat zero across opening, movement, AND closing figures. `is_balanced: true` alongside `is_empty: true` is only a vacuous 0 == 0 — NOT a reconciliation signal, and NOT proof the org had no activity (posted journal entries can exist but be unmapped to a ledger account, and the authoritative books may live in a connected GL). A dormant window that still carries forward real opening/closing balances is NOT empty."
                            },
                            "total_debits": {
                              "type": "string"
                            },
                            "total_credits": {
                              "type": "string"
                            },
                            "warnings": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              },
                              "description": "Human-readable `[WARNING]` strings for any raised condition (does-not-foot, or empty)."
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/closing-positions": {
      "get": {
        "operationId": "getClosingPositionsReport",
        "summary": "Closing positions",
        "description": "Returns per-token closing position data including cost basis, market value, and unrealized gain/loss. Paginated.\n\nFIAT / cash positions (e.g. USD) are EXCLUDED by default — the report shows crypto/token lots only. To include a fiat asset, pass it explicitly via `asset_types` (e.g. `asset_types=['USD']`); any 'total positions' you sum from the default response therefore excludes fiat.\n\nThis reads the cost-basis lot subledger (AssetV2 — the accounting view), NOT live wallet holdings, so its quantities can diverge from `get_live_balances` / `get_token_balances` (which read current on-chain holdings). Use this for accounting cost basis / unrealized gain-loss; for the current on-chain quantity of an asset, use the live-balance tools.\n\nPagination: positions are paginated in memory and the response carries `total_count` — the full post-filter position count across ALL pages. A single page (or a sum over page 1) understates the portfolio unless `data.length` already equals `total_count`; use `total_count` as the denominator to detect a partial read before reporting a portfolio-wide figure.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "selected_date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Snapshot date (ISO 8601)."
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "source_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Source ID filters.",
            "style": "form",
            "explode": true
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Closing positions retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor", "total_count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ClosingPosition"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Total number of asset positions across all pages (the full post-filter set). Positions are paginated in memory, so summing only the first page understates the portfolio unless `data.length` already equals `total_count`; use this as the denominator to detect a partial read."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/asset-roll-forward": {
      "get": {
        "operationId": "getAssetRollForwardReport",
        "summary": "Asset roll forward",
        "description": "Returns per-token quantity movement data (opening, inflows, outflows, closing) for a specific accounting period. Paginated with sort support.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. Prefixed accounting period ID (`ap_`)."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Additional period IDs.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "source_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Source ID filters.",
            "style": "form",
            "explode": true
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "sort_by",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "token",
                "open_running_balance",
                "open_historical_balance",
                "open_variance",
                "inflows",
                "outflows",
                "movement_net",
                "close_running_balance",
                "close_historical_balance",
                "close_variance"
              ],
              "default": "token"
            },
            "description": "Sort field."
          },
          {
            "name": "sort_direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["asc", "desc"],
              "default": "asc"
            },
            "description": "Sort direction."
          }
        ],
        "responses": {
          "200": {
            "description": "Asset roll forward retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "rows": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/AssetRollForwardRow"
                          }
                        },
                        "period_start": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/asset-roll-forward/drill-down": {
      "get": {
        "operationId": "getAssetRollForwardDrillDown",
        "summary": "Asset roll forward drill-down",
        "description": "Returns per-wallet breakdown for a specific token within an accounting period. Shows subledger vs on-chain balance comparison.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. Asset token symbol (e.g., `ETH`)."
          },
          {
            "name": "accounting_period_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. Prefixed accounting period ID (`ap_`)."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Additional period IDs.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "source_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Source ID filters.",
            "style": "form",
            "explode": true
          }
        ],
        "responses": {
          "200": {
            "description": "Drill-down data retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WalletBreakdown"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/asset-roll-forward/export": {
      "get": {
        "operationId": "exportAssetRollForward",
        "summary": "Export asset roll forward (CSV)",
        "description": "Exports the full asset roll-forward report as a streaming CSV file using chunked transfer encoding for large datasets. The roll-forward shows opening balance, period activity (acquisitions, disposals, revaluations), and closing balance per asset across the queried window. For the on-screen / paginated version use `tools.reports.get_asset_roll_forward_report`.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. Prefixed accounting period ID (`ap_`)."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Additional period IDs.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "source_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Source ID filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "classifications",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Classification filters.",
            "style": "form",
            "explode": true
          }
        ],
        "responses": {
          "200": {
            "description": "CSV file streamed.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            },
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string",
                  "examples": ["attachment; filename=\"asset_roll_forward_1710504000.csv\""]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/schedule-of-dispositions": {
      "get": {
        "operationId": "getScheduleOfDispositions",
        "summary": "Schedule of dispositions",
        "description": "Returns asset lot records that have one or more disposals (sales), with the full disposal history per lot — disposal date, proceeds, cost basis applied, realized gain / loss. Paginated. For aggregated realized gains use `tools.reports.get_realized_gains_report` with `summary=true`, or pass `?summary=true` here — both emit the same server-side `RealizedGainsSummary` block over the same filters, so period totals never require walking every page. The summary reports per-currency `by_currency[]` blocks; flat totals appear only on a single-currency scope — never add amounts across currencies.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed accounting period ID (`ap_`)."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Additional period IDs.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters.",
            "style": "form",
            "explode": true
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "summary",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, adds a top-level `summary` object — a single server-side aggregation over the same filters as the page. Use `summary=true&limit=1` to answer period-total questions in one call instead of paginating."
          }
        ],
        "responses": {
          "200": {
            "description": "Disposition records retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DispositionRecord"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "summary": {
                      "$ref": "#/components/schemas/RealizedGainsSummary"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/balance-reconciliation": {
      "get": {
        "operationId": "getBalanceReconciliation",
        "summary": "Balance reconciliation",
        "description": "Returns an on-chain (or exchange) vs subledger comparison over the [start_date, end_date] window. IMPORTANT — this is a WINDOW-MOVEMENT reconciliation, NOT an absolute point-in-time holdings check: `on_chain_balance`, `ledger_balance`, and `delta` are NET MOVEMENT within the window (received − sent / signed netAmount sum), so they equal the true absolute holding ONLY when start_date predates all wallet activity. For a narrow window (e.g. this month / yesterday) a mostly-dormant wallet returns ~0 for all three — that is NOT evidence of a reconciled balance. To reconcile absolute holdings as of end_date, read `end_on_chain_balance` (absolute on-chain holding at end_date, populated on Moralis/Babylon/SUI chains; null elsewhere); there is currently no absolute ledger-as-of-end figure in this response. `on_chain_balance` is legitimately negative on a net-outflow window. `data_quality.is_complete` reflects fetch success and, in the default `strict` mode, is effectively always true on a 200 (incomplete data returns 503 instead) — do not read `is_complete: true` as 'balances reconcile'. In `strict` mode the endpoint returns 503 when source data is incomplete (e.g., an exchange sync is still running) — caller should retry. (Note: `tools.reports.get_accounting_percentage` measures bookkeeping COVERAGE — the share of transactions with a posted journal entry — NOT on-chain-vs-ledger reconciliation; it is not a substitute for this tool.)",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "start_date",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. Start of reconciliation window (ISO 8601)."
          },
          {
            "name": "end_date",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. End of reconciliation window (ISO 8601)."
          },
          {
            "name": "financial_account_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed financial account IDs (`fac_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "classifications",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Classification filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "chains",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Blockchain chain filters.",
            "style": "form",
            "explode": true
          },
          {
            "name": "strict_mode",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            },
            "description": "Fail with 503 if data is incomplete. Default: `true`."
          }
        ],
        "responses": {
          "200": {
            "description": "Reconciliation data retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BalanceReconciliation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "503": {
            "description": "Data incomplete (strict mode). Retry after the specified interval.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "statusCode": {
                      "type": "integer"
                    },
                    "errorCode": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    },
                    "retry_after": {
                      "type": "integer",
                      "description": "Seconds to wait before retrying."
                    },
                    "data_quality": {
                      "type": "object",
                      "properties": {
                        "is_complete": {
                          "type": "boolean"
                        },
                        "failed_chains": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "failure_rate": {
                          "type": "number"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/reports/overview": {
      "get": {
        "operationId": "getOverviewReport",
        "summary": "Overview metrics",
        "description": "Returns high-level portfolio metrics — total value, monthly income, and monthly expenses with period-over-period change. Computes LIVE by default (matches the legacy overview); pass `real_time=false` to read the cached snapshot. The response carries `computed_at`/`is_stale` so a cached value is never mistaken for fresh. For a definitive \"how much cash / what's the portfolio worth right now\" answer, prefer the live treasury/balances feed (`tools.treasury.get_treasury_cash_position` / `tools.balances.get_live_balances`) — the same source the in-app copilot uses.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "real_time",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            },
            "description": "Compute live vs read the cache. Default: `true` (live). Pass `false` to read the cached snapshot, which may be stale — check `is_stale`/`computed_at` in the response."
          }
        ],
        "responses": {
          "200": {
            "description": "Overview retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/OverviewReport"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/accounting-percentage": {
      "get": {
        "operationId": "getAccountingPercentage",
        "summary": "Accounting percentage",
        "description": "Returns the percentage of transactions inside a specific accounting period that have at least one posted journal entry (i.e., are \"accounted for\"). Useful for close-readiness dashboards. For per-transaction detail of the un-accounted set use `tools.transactions.list_transactions` with `has_accounting: false`.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Required. Prefixed accounting period ID (`ap_`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Accounting percentage retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "accounted_transactions": {
                          "type": "integer"
                        },
                        "total_transactions": {
                          "type": "integer"
                        },
                        "percentage": {
                          "type": "number",
                          "description": "Percentage 0-100."
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "accounted_transactions": 142,
                    "total_transactions": 200,
                    "percentage": 71
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/realized-gains/export": {
      "get": {
        "operationId": "exportRealizedGains",
        "summary": "Export realized gains (CSV)",
        "description": "Exports realized capital-gains data as a streaming CSV file — processes asset lots in batches of 1000 with backpressure handling so large datasets stream cleanly without memory pressure. Each row carries the disposal lot, cost basis, proceeds, and gain / loss. For the on-screen view use `tools.reports.get_realized_gains_report`.",
        "tags": ["Reports"],
        "parameters": [
          {
            "name": "accounting_period_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Prefixed accounting period ID (`ap_`)."
          },
          {
            "name": "accounting_period_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Additional period IDs.",
            "style": "form",
            "explode": true
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Prefixed legal entity IDs (`le_`).",
            "style": "form",
            "explode": true
          },
          {
            "name": "asset_types",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Asset type filters.",
            "style": "form",
            "explode": true
          }
        ],
        "responses": {
          "200": {
            "description": "CSV file streamed.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            },
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string",
                  "examples": ["attachment; filename=\"realized_gains_1710504000.csv\""]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/asset-balances-by-day": {
      "post": {
        "operationId": "createAssetBalancesByDayReport",
        "summary": "Create asset balances by day report",
        "description": "Creates an asynchronous report of daily asset balances across wallets. Provide EITHER a `start_date` + `end_date` range OR an `accounting_period_start_date_utcs` entry. Returns a cached report (with the full report body inline) if one exists for the same parameters (unless `force_refresh` is set) — 200 with `from_cache: true`. New reports return 202 with a `job` handle pollable via `GET /v1/jobs/{id}/status`.",
        "tags": ["Reports"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Either `start_date` + `end_date`, or `accounting_period_start_date_utcs`, must be provided.",
                "properties": {
                  "start_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Report start date (ISO 8601). Required together with `end_date` unless an accounting period is given."
                  },
                  "end_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Report end date (ISO 8601). Required together with `start_date` unless an accounting period is given."
                  },
                  "accounting_period_start_date_utcs": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "maxItems": 1,
                    "description": "Alternative to a date range: a single accounting-period start date (ISO 8601). The report spans that period."
                  },
                  "chains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "asset_types": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "financial_account_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Prefixed financial account IDs (`fac_`)."
                  },
                  "force_refresh": {
                    "type": "boolean",
                    "default": false,
                    "description": "Bypass cache and regenerate."
                  }
                }
              },
              "example": {
                "start_date": "2024-01-01T00:00:00Z",
                "end_date": "2024-03-31T23:59:59Z",
                "chains": ["ethereum", "polygon"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cached report found — `report` is inline, `job` is null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "report_id": {
                          "type": ["string", "null"],
                          "examples": ["abl_abc123"]
                        },
                        "from_cache": {
                          "type": "boolean"
                        },
                        "report": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/AssetBalanceByDayReport"
                            },
                            {
                              "type": "null"
                            }
                          ]
                        },
                        "job": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/AssetBalanceByDayJob"
                            },
                            {
                              "type": "null"
                            }
                          ]
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "report_id": "abl_abc123",
                    "from_cache": true,
                    "report": {
                      "id": "abl_abc123",
                      "from_date": "2024-01-01T00:00:00Z",
                      "to_date": "2024-03-31T23:59:59Z"
                    },
                    "job": null
                  }
                }
              }
            }
          },
          "202": {
            "description": "Report generation started — `job` is returned, `report` is null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "report_id": {
                          "type": ["string", "null"]
                        },
                        "from_cache": {
                          "type": "boolean"
                        },
                        "report": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/AssetBalanceByDayReport"
                            },
                            {
                              "type": "null"
                            }
                          ]
                        },
                        "job": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/AssetBalanceByDayJob"
                            },
                            {
                              "type": "null"
                            }
                          ]
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "report_id": "abl_abc123",
                    "from_cache": false,
                    "report": null,
                    "job": {
                      "id": "job_def456",
                      "unique_id": "…",
                      "job_status": "in_progress"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/asset-balances-by-day/{id}": {
      "get": {
        "operationId": "getAssetBalancesByDayReport",
        "summary": "Get asset balances by day report",
        "description": "Retrieves an already-created asset-balances-by-day report BY ID — it does not compute a series itself. The report must be built FIRST via `tools.reports.create_asset_balances_by_day_report` (`POST /reports/asset-balances-by-day`), which returns the id (and often the result inline); this endpoint only fetches one that already exists and 404s / errors without a valid report id.\n\nSo for balances over MULTIPLE days / a date range / a month, START at `tools.reports.create_asset_balances_by_day_report` — do NOT route those questions directly here. That create step is a MUTATING tool, so on a read-only session it is unavailable; there, use `tools.balances.list_balances` for GL-derived period-end book balances instead. For a single point-in-time live balance ('what's in the wallet right now') use `tools.balances.get_token_balances` instead. (Mirrors copilot onchain-analytics/index.ts:63.)",
        "tags": ["Reports"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Report retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AssetBalanceByDayReport"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/export": {
      "post": {
        "operationId": "exportReport",
        "summary": "Export a report asynchronously",
        "description": "Enqueues a background job that generates the named report and uploads the resulting CSV or XLSX to private storage. Replaces the legacy per-report `/export` siblings (only `/asset-roll-forward/export` and `/realized-gains/export` exist today, both synchronous and prone to timeouts on large datasets).\n\nReturns `202 Accepted` with a `job_id` on a new job, or `200 OK` with `from_cache: true` when an identical in-flight job already exists in the same organization within the last hour (content-hashed by `report_type`, `format`, and sorted `parameters`).\n\nBoth responses include a `Location` header pointing at the polling URL: `GET /v1/background-jobs/{job_id}`. The polling response surfaces the terminal `result: { download_url, file_name, size_bytes, format }` once the job has completed. The `download_url` is a 24-hour signed URL — treat it as a bearer capability.",
        "tags": ["Reports"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["report_type", "format"],
                "properties": {
                  "report_type": {
                    "type": "string",
                    "enum": [
                      "balance-sheet",
                      "income-statement",
                      "trial-balance",
                      "closing-positions",
                      "asset-roll-forward",
                      "schedule-of-dispositions",
                      "balance-reconciliation",
                      "overview",
                      "accounting-percentage",
                      "realized-gains"
                    ],
                    "description": "Report to export. Case-insensitive. `balance-reconciliation` requires `parameters.start_date` and `parameters.end_date` to scope the on-chain balance fetch window."
                  },
                  "format": {
                    "type": "string",
                    "enum": ["csv", "xlsx"],
                    "description": "Output file format. Case-insensitive."
                  },
                  "parameters": {
                    "type": "object",
                    "description": "Filter envelope. Required fields depend on `report_type` — `balance-sheet`, `income-statement`, `asset-roll-forward`, and `accounting-percentage` require an accounting period; `trial-balance` accepts either an accounting period or a `start_date` + `end_date` pair.",
                    "properties": {
                      "accounting_period_id": {
                        "type": "string",
                        "description": "Prefixed accounting period id (`ap_…`)."
                      },
                      "accounting_period_ids": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "tag_id": {
                        "type": "string",
                        "description": "Prefixed tag id (`tag_…`)."
                      },
                      "legal_entity_ids": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "financial_account_ids": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "source_ids": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "asset_types": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "chains": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "classifications": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 100
                      },
                      "start_date": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "end_date": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "selected_date": {
                        "type": "string"
                      },
                      "strict_mode": {
                        "type": "boolean"
                      },
                      "real_time": {
                        "type": "boolean"
                      }
                    },
                    "additionalProperties": false
                  }
                }
              },
              "example": {
                "report_type": "balance-sheet",
                "format": "xlsx",
                "parameters": {
                  "accounting_period_id": "ap_507f1f77bcf86cd799439011"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Identical in-flight job already exists. Caller polls the same `job_id`.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                },
                "description": "Polling URL: `/v1/background-jobs/{job_id}`."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReportExportEnvelope"
                },
                "example": {
                  "data": {
                    "job_id": "rxj_b1d4f6e0-1234-4abc-8def-9876543210ab",
                    "status": "queued",
                    "type": "report_export",
                    "format": "xlsx",
                    "from_cache": true
                  }
                }
              }
            }
          },
          "202": {
            "description": "New export job enqueued.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                },
                "description": "Polling URL: `/v1/background-jobs/{job_id}`."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReportExportEnvelope"
                },
                "example": {
                  "data": {
                    "job_id": "rxj_b1d4f6e0-1234-4abc-8def-9876543210ab",
                    "status": "queued",
                    "type": "report_export",
                    "format": "xlsx",
                    "from_cache": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/background-jobs/{id}": {
      "get": {
        "operationId": "getBackgroundJob",
        "summary": "Get a background job by id",
        "description": "Retrieves the current state of an ad-hoc async background job by its prefixed id. Use this to poll jobs enqueued by other tools, with per-type details under `params` and `result`. Supported prefixes include `caj_…` (cash-application auto-match), `rxj_…` (report export), and `cls_…` (direct classification and posting).\n\nFor classification, call this in a FOLLOW-UP `execute_code` invocation after the enqueue call returns; never spin or sleep inside one sandbox invocation because each invocation has a 60-second wall-clock cap. A completed `cls_` job already contains the final auto_posted, pending_review, unclassified, skipped, and failed outcomes; no second classification call is needed. Classification creates Entendre journal entries but does not sync them to QuickBooks or another GL.\n\nCross-org or unknown ids collapse to `404`.",
        "x-mcp-typed-response": true,
        "tags": ["Jobs"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed job id (for example `rxj_…` or `cls_…`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Job state retrieved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BackgroundJob"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "rxj_b1d4f6e0-1234-4abc-8def-9876543210ab",
                    "type": "report_export",
                    "status": "completed",
                    "params": {
                      "report_type": "balance-sheet",
                      "format": "xlsx",
                      "parameters": {
                        "accounting_period_id": "507f1f77bcf86cd799439011"
                      }
                    },
                    "result": {
                      "download_url": "https://storage.googleapis.com/entendre-report-exports-production/...",
                      "file_name": "balance-sheet_rxj_b1d4f6e0-1234-4abc-8def-9876543210ab.xlsx",
                      "size_bytes": 81920,
                      "format": "xlsx"
                    },
                    "error": null,
                    "created_by": "usr_507f1f77bcf86cd799439055",
                    "created_at": "2026-06-03T08:00:00.000Z",
                    "updated_at": "2026-06-03T08:02:30.000Z",
                    "completed_at": "2026-06-03T08:02:30.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/revenue": {
      "get": {
        "operationId": "getFinancialInsightRevenue",
        "summary": "Revenue",
        "description": "Returns income over time as a series of period buckets — the top-line revenue stream only.\n\n### Data source\n\n- `source=gl` — credits posted to revenue ledger accounts.\n- `source=transactions` — `IN`-direction transactions grouped by transaction type.\n\n### When to use this\n\nWhen the question is \"how much money came in?\"\n\n### Pick a different endpoint when…\n\n- You want revenue minus expenses → `GET /v1/reports/financial-insights/profit`.\n- You want the expense side → `GET /v1/reports/financial-insights/expenses`.\n- You want runway → `GET /v1/reports/financial-insights/runway`.\n- You want cash position → `GET /v1/reports/financial-insights/cash-flow`.\n- You want source × transaction-type breakdown → `GET /v1/reports/financial-insights/spending`.\n- You want spending pace → `GET /v1/reports/financial-insights/burn-rate`.\n\n### Data-quality disclosure\n\nWith `source=transactions`, the response's `data.data_quality` block reports what was excluded from these figures (spam count + amount, non-P&L-classification count, no-posted-JE count) with [WARNING] prose — surface those warnings whenever the excluded activity is material to the question.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` = raw synced cash in/out, accounting-independent (does not need posted journal entries); `gl` = posted-JE P&L (only complete once bookkeeping is caught up). The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` so an org mid-bookkeeping still gets a real number and to match the in-app copilot. Pass `gl` explicitly for the books-based view. Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["total", "currency", "time_series"],
                      "properties": {
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        },
                        "total": {
                          "type": "string",
                          "description": "Total income over the window (decimal string)."
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "time_series": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1FinancialInsightSeriesPoint"
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/profit": {
      "get": {
        "operationId": "getFinancialInsightProfit",
        "summary": "Profit (revenue minus expenses)",
        "description": "Returns net income per period — revenue minus expenses for each bucket in the time series.\n\nComposes the same underlying data as `GET /v1/reports/financial-insights/revenue` and `GET /v1/reports/financial-insights/expenses` but returns the delta directly.\n\n### When to use this\n\nWhen the question is \"how much profit did we make?\" or \"are we net positive?\"\n\n### Pick a different endpoint when…\n\n- You want the burn rate (cash outflow pace) → `GET /v1/reports/financial-insights/burn-rate`.\n- You want a runway projection (months remaining) → `GET /v1/reports/financial-insights/runway`.\n- You want net cash movement (includes non-P&L transfers) → `GET /v1/reports/financial-insights/cash-flow`.\n- You want \"where did money flow\" (source × transaction type) → `GET /v1/reports/financial-insights/spending`.\n\n### Data-quality disclosure\n\nWith `source=transactions`, the response's `data.data_quality` block reports what was excluded from these figures (spam count + amount, non-P&L-classification count, no-posted-JE count) with [WARNING] prose — surface those warnings whenever the excluded activity is material to the question.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` (default over MCP) = cash-basis (raw synced cash in/out), matching the in-app copilot's financial-insights tool and the sibling insights endpoints. `gl` = accrual P&L from posted journal entries (books-based). Cash-basis excludes unpaid AP, accruals, non-cash entries, and unclassified outflows, so it can overstate profit — the response `meta.basis` / `meta.cash_basis_caveat` disclose this; for the reconciled net-income/P&L answer prefer `gl` or the income-statement report. The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` (copilot parity + works for orgs mid-bookkeeping). Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["total_revenue", "total_expenses", "total_profit", "currency", "time_series"],
                      "properties": {
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        },
                        "total_revenue": {
                          "type": "string",
                          "description": "Total income (decimal string)."
                        },
                        "total_expenses": {
                          "type": "string",
                          "description": "Total expenses (decimal string)."
                        },
                        "total_profit": {
                          "type": "string",
                          "description": "total_revenue minus total_expenses (decimal string)."
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "time_series": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": ["period_start", "period_end", "revenue", "expenses", "profit"],
                            "properties": {
                              "period_start": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "period_end": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "revenue": {
                                "type": "string"
                              },
                              "expenses": {
                                "type": "string"
                              },
                              "profit": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/expenses": {
      "get": {
        "operationId": "getFinancialInsightExpenses",
        "summary": "Expense breakdown",
        "description": "Returns expense totals over the period, one series per category. The grouping key depends on `source`: `source=gl` groups by ledger-account (chart-of-accounts) expense category; `source=transactions` (the MCP default) groups by transaction type.\n\n### When to use this\n\nWhen the question is \"what categories are we spending on?\" — use `source=gl` for chart-of-accounts categories or `source=transactions` for transaction-type categories.\n\n### Pick a different endpoint when…\n\n- You want a transaction-level view of net flows by source / transaction type (crypto-`DEPOSIT` vs bank-`WITHDRAWAL`) → `GET /v1/reports/financial-insights/spending`.\n- You want the revenue side → `GET /v1/reports/financial-insights/revenue`.\n- You want revenue minus expenses → `GET /v1/reports/financial-insights/profit`.\n- You want burn rate or runway → `GET /v1/reports/financial-insights/burn-rate` / `GET /v1/reports/financial-insights/runway`.\n- You want per-period net of P&L-classified cash activity → `GET /v1/reports/financial-insights/cash-flow` (transfers are excluded there too — it is NOT a full liquidity statement).\n\n### Data-quality disclosure\n\nWith `source=transactions`, the response's `data.data_quality` block reports what was excluded from these figures (spam count + amount, non-P&L-classification count, no-posted-JE count) with [WARNING] prose — surface those warnings whenever the excluded activity is material to the question.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` = raw synced cash in/out, accounting-independent (does not need posted journal entries); `gl` = posted-JE P&L (only complete once bookkeeping is caught up). The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` so an org mid-bookkeeping still gets a real number and to match the in-app copilot. Pass `gl` explicitly for the books-based view. Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["total", "by_category", "currency", "time_series"],
                      "properties": {
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        },
                        "total": {
                          "type": "string",
                          "description": "Total expenses over the window (decimal string)."
                        },
                        "by_category": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1FinancialInsightCategoryAmount"
                          }
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "time_series": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": ["period_start", "period_end", "total", "categories"],
                            "properties": {
                              "period_start": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "period_end": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "total": {
                                "type": "string"
                              },
                              "categories": {
                                "type": "array",
                                "items": {
                                  "$ref": "#/components/schemas/V1FinancialInsightCategoryAmount"
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/spending": {
      "get": {
        "operationId": "getFinancialInsightSpending",
        "summary": "Spending by source × transaction type",
        "description": "Returns outflow grouped by `source_type` × transaction type (exposed in the legacy `classification` field) — for example crypto-`DEPOSIT` vs bank-`WITHDRAWAL` vs card-`FEE`.\n\n### When to use this\n\nWhen the question is about **WHERE** money flowed (which wallet, which payment rail) rather than which expense category it was booked under. Transactions-level view, not chart-of-accounts.\n\n### Pick a different endpoint when…\n\n- You want ledger-account expense-category grouping → `GET /v1/reports/financial-insights/expenses` (chart-of-accounts view).\n- You want per-month pace of outflow → `GET /v1/reports/financial-insights/burn-rate`.\n- You want net cash movement → `GET /v1/reports/financial-insights/cash-flow`.\n- You want the income side → `GET /v1/reports/financial-insights/revenue`.\n- You want revenue minus expenses → `GET /v1/reports/financial-insights/profit`.\n- You want months-of-runway → `GET /v1/reports/financial-insights/runway`.\n\nThis groups outflow by `source_type` × transaction type only. For a finer cut — per-cardholder, per-merchant, or per-card — this endpoint cannot answer; query the rows with `tools.transactions.list_transactions` and reason over them. (Mirrors copilot financial-insights/index.ts:96.)\n\n### Data-quality disclosure\n\nWith `source=transactions`, the response's `data.data_quality` block reports what was excluded from these figures (spam count + amount, non-P&L-classification count, no-posted-JE count) with [WARNING] prose — surface those warnings whenever the excluded activity is material to the question.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` = raw synced cash in/out, accounting-independent (does not need posted journal entries); `gl` = posted-JE P&L (only complete once bookkeeping is caught up). The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` so an org mid-bookkeeping still gets a real number and to match the in-app copilot. Pass `gl` explicitly for the books-based view. Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["total", "by_source_type", "by_classification", "currency", "time_series"],
                      "properties": {
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        },
                        "total": {
                          "type": "string",
                          "description": "Total spending over the window (decimal string)."
                        },
                        "by_source_type": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1FinancialInsightSpendingEntry"
                          }
                        },
                        "by_classification": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1FinancialInsightSpendingEntry"
                          }
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "time_series": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": ["period_start", "period_end", "total", "by_source_type", "by_classification"],
                            "properties": {
                              "period_start": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "period_end": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "total": {
                                "type": "string"
                              },
                              "by_source_type": {
                                "type": "array",
                                "items": {
                                  "$ref": "#/components/schemas/V1FinancialInsightSpendingEntry"
                                }
                              },
                              "by_classification": {
                                "type": "array",
                                "items": {
                                  "$ref": "#/components/schemas/V1FinancialInsightSpendingEntry"
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/burn-rate": {
      "get": {
        "operationId": "getFinancialInsightBurnRate",
        "summary": "Average monthly burn",
        "description": "Returns the average monthly NET cash burn (outflows minus inflows) over the queried window, plus a `trend` array of per-period net-burn buckets (`period_start`, `period_end`, `amount`) so callers can chart how burn moved over time. `cash_flow_positive` is `true` (and average burn is negative) when inflows cover outflows.\n\n### When to use this\n\nWhen the question is \"how fast are we spending?\" or \"what's our burn rate?\"\n\n### Pick a different endpoint when\u2026\n\n- You want the total expense series (not per-month average) \u2192 `GET /v1/reports/financial-insights/expenses` or `GET /v1/reports/financial-insights/spending`.\n- You want months of runway remaining \u2192 `GET /v1/reports/financial-insights/runway` (don't divide manually).\n- You want top-line income \u2192 `GET /v1/reports/financial-insights/revenue`.\n- You want net P&L (revenue minus expenses) \u2192 `GET /v1/reports/financial-insights/profit`.\n- You want net liquidity movement \u2192 `GET /v1/reports/financial-insights/cash-flow`.\n\n### Granularity caveat\n\nUnder the default `source=transactions`, the `granularity` parameter is currently INERT \u2014 results are always bucketed monthly regardless of `daily`/`weekly`, and `meta.granularity` reports `monthly`. Do not tell the user you produced weekly/daily buckets on the transactions basis; if sub-monthly resolution is required, say it is not yet available here.\n\n### Data-quality disclosure\n\nWith `source=transactions`, the response's `data.data_quality` block reports what was excluded from these figures (spam count + amount, non-P&L-classification count, no-posted-JE count) with [WARNING] prose \u2014 surface those warnings whenever the excluded activity is material to the question.\n\n### Which source to report\n\nDefault is source=transactions (cash-basis); switch to source=gl only on an explicit user request ('from the GL', 'from the P and L', 'based on accounting'). Either way, STATE which source view (accrual/GL vs transactions) you used in your answer.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` = raw synced cash in/out, accounting-independent (does not need posted journal entries); `gl` = posted-JE P&L (only complete once bookkeeping is caught up). The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` so an org mid-bookkeeping still gets a real number and to match the in-app copilot. Pass `gl` explicitly for the books-based view. Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly. INERT on this endpoint: burn rate always computes on monthly buckets regardless of the value passed, and `meta.granularity` reports monthly. See the Granularity caveat in the description."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "average_monthly_burn",
                        "cash_flow_positive",
                        "total_expenses",
                        "total_income",
                        "months_analyzed",
                        "currency",
                        "trend",
                        "no_data"
                      ],
                      "properties": {
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        },
                        "average_monthly_burn": {
                          "type": "string",
                          "description": "Net burn (total outflows minus inflows) divided by months in the window (decimal string). Negative when the org is net cash-flow positive."
                        },
                        "cash_flow_positive": {
                          "type": "boolean",
                          "description": "True when net burn is <= 0 (inflows cover outflows over the window)."
                        },
                        "total_expenses": {
                          "type": "string",
                          "description": "Total expenses (outflows) over the window (decimal string)."
                        },
                        "total_income": {
                          "type": "string",
                          "description": "Total income (inflows) over the window (decimal string)."
                        },
                        "months_analyzed": {
                          "type": "integer",
                          "description": "Number of whole months in the window. Normally >= 1; 0 only when source=gl and the window overlaps no accounting period (see no_data)."
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "trend": {
                          "type": "array",
                          "description": "Per-bucket net burn (outflows minus inflows) as single-amount series points.",
                          "items": {
                            "$ref": "#/components/schemas/V1FinancialInsightSeriesPoint"
                          }
                        },
                        "no_data": {
                          "type": "boolean",
                          "description": "Always present. True only when source=gl and the requested window overlaps zero accounting periods, so there is no data to analyze; false otherwise. When true, months_analyzed is 0, the amounts are '0', and cash_flow_positive is false — do NOT read that as a healthy break-even org; treat it as no data for the window."
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/runway": {
      "get": {
        "operationId": "getFinancialInsightRunway",
        "summary": "Months of runway remaining",
        "description": "Returns months of runway remaining, computed as current cash position divided by average monthly NET burn (outflows minus inflows). When the org is cash-flow positive (net burn <= 0) runway is not applicable: `months_remaining` is `null` and `cash_flow_positive` is `true` \u2014 do not treat a missing runway as zero or infinite; report that the org is cash-flow positive.\n\n**Web3-aware**: cash position is `fiat + stablecoins` only. Volatile crypto holdings are excluded from the cash denominator.\n\n### When to use this\n\nWhen the question is \"how long can we survive at the current spend rate?\"\n\n### Pick a different endpoint when\u2026\n\n- You want the burn rate itself \u2192 `GET /v1/reports/financial-insights/burn-rate`.\n- You want net cash movement \u2192 `GET /v1/reports/financial-insights/cash-flow`.\n- You want a spending breakdown (WHERE the burn is going) \u2192 `GET /v1/reports/financial-insights/spending` or `GET /v1/reports/financial-insights/expenses`.\n- You want top-line income \u2192 `GET /v1/reports/financial-insights/revenue`.\n- You want net P&L \u2192 `GET /v1/reports/financial-insights/profit`.\n\n### Do NOT hand-derive runway\n\nThis single endpoint is terminal for runway. Never approximate it by chaining `tools.reports.get_balance_sheet_report` + `tools.reports.get_income_statement_report` and dividing \u2014 that path is fragile to a single mis-classified journal entry and diverges from this tool's cash-basis computation. (Mirrors copilot financial-insights/index.ts:68.)\n\n### Data-quality disclosure\n\nWith `source=transactions`, the response's `data.data_quality` block reports what was excluded from these figures (spam count + amount, non-P&L-classification count, no-posted-JE count) with [WARNING] prose \u2014 surface those warnings whenever the excluded activity is material to the question.\n\n### Granularity caveat\n\nThe granularity parameter is INERT here: runway hard-codes monthly bucketing internally regardless of daily/weekly and regardless of source, and meta.granularity reports monthly. Do not tell the user you produced weekly/daily buckets; if sub-monthly resolution is required, say it is not available here.\n\n### Which source to report\n\nDefault is source=transactions (cash-basis); switch to source=gl only on an explicit user request ('from the GL', 'from the P and L', 'based on accounting'). Either way, STATE which source view (accrual/GL vs transactions) you used in your answer.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` = raw synced cash in/out, accounting-independent (does not need posted journal entries); `gl` = posted-JE P&L (only complete once bookkeeping is caught up). The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` so an org mid-bookkeeping still gets a real number and to match the in-app copilot. Pass `gl` explicitly for the books-based view. Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly. INERT on this endpoint: runway always computes on monthly buckets regardless of the value passed and regardless of source, see the Granularity caveat in the description."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "months_remaining",
                        "cash_flow_positive",
                        "current_cash",
                        "average_monthly_burn",
                        "currency",
                        "no_data"
                      ],
                      "properties": {
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        },
                        "months_remaining": {
                          "type": "string",
                          "nullable": true,
                          "description": "current_cash divided by average_monthly_burn (decimal string). `null` when the org is cash-flow positive (net burn is zero or negative) — runway is not applicable, so read `cash_flow_positive` instead of treating this as a number."
                        },
                        "cash_flow_positive": {
                          "type": "boolean",
                          "description": "True when net burn (outflows minus inflows) is <= 0. When true, months_remaining is null and runway is not applicable."
                        },
                        "current_cash": {
                          "type": "string",
                          "description": "Current cash position (decimal string)."
                        },
                        "average_monthly_burn": {
                          "type": "string",
                          "description": "Net burn (total outflows minus inflows) divided by months in the window (decimal string). Negative when the org is net cash-flow positive."
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "no_data": {
                          "type": "boolean",
                          "description": "Always present. True only when source=gl and the requested window overlaps zero accounting periods, so runway cannot be computed; false otherwise. When true, months_remaining is null, average_monthly_burn is '0', and cash_flow_positive is false (current_cash is still the real point-in-time balance) — do NOT read the null months_remaining as cash-flow positive; treat it as no data for the window."
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/reports/financial-insights/cash-flow": {
      "get": {
        "operationId": "getFinancialInsightCashFlow",
        "summary": "Net + cumulative cash flow",
        "description": "Returns net cash movement per period bucket (inflows minus outflows) plus a cumulative running total across the window.\n\n### What is (and is NOT) included\n\nWith `source=transactions`, figures cover ONLY transactions whose classification is in the P&L set (income: INCOME / STAKING_REWARD / VALIDATOR_REWARD / CLAIM_REWARD / INVOICE / REFUND; expense: EXPENSE / FEE / BILL / CHARGEBACK). Transfers, swaps, bridges, deposits, withdrawals, and unknown/unclassified transactions are EXCLUDED, as are spam-flagged transactions — this is NOT a complete cash-movement statement. The response's `data.data_quality` block reports exactly what was excluded (spam count + amount, non-P&L-classification count, no-posted-JE count) with `[WARNING]` prose — surface those warnings whenever the excluded buckets are material to the question.\n\n### When to use this\n\nWhen the question is about the net of P&L-classified cash activity per period. For total liquidity movement including transfers, analyze `tools.transactions.list_transactions` rows directly.\n\n### Pick a different endpoint when…\n\n- You want income vs expenses with a bottom line (P&L view over the same P&L-classified set) → `GET /v1/reports/financial-insights/profit`.\n- You want just the outflow side (pace) → `GET /v1/reports/financial-insights/burn-rate`.\n- You want a runway projection → `GET /v1/reports/financial-insights/runway`.\n- You want the revenue stream → `GET /v1/reports/financial-insights/revenue`.\n- You want chart-of-accounts grouping of expenses → `GET /v1/reports/financial-insights/expenses`.\n- You want source × transaction-type grouping → `GET /v1/reports/financial-insights/spending`.",
        "tags": ["Financial Insights"],
        "parameters": [
          {
            "name": "source",
            "in": "query",
            "x-mcp-wrapper-default": "transactions",
            "schema": {
              "type": "string",
              "enum": ["gl", "transactions"]
            },
            "description": "Data source. `transactions` = raw synced cash in/out, accounting-independent (does not need posted journal entries); `gl` = posted-JE P&L (only complete once bookkeeping is caught up). The HTTP API default is `gl`; the MCP wrapper defaults to `transactions` so an org mid-bookkeeping still gets a real number and to match the in-app copilot. Pass `gl` explicitly for the books-based view. Note: `gl` is monthly period-resolution — a window narrower than a full calendar month returns the whole containing month (the response `meta.period_snapped_note` flags this); use `transactions` for sub-month or day-level detail."
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["daily", "weekly", "monthly"]
            },
            "description": "Time bucketing. Defaults to monthly."
          },
          {
            "name": "start_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "end_date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "required": true
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "account_ids",
            "in": "query",
            "description": "Comma-separated `fac_…` financial account IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Time series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "meta"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["net_flow", "total_inflows", "total_outflows", "currency", "time_series"],
                      "properties": {
                        "net_flow": {
                          "type": "string",
                          "description": "total_inflows minus total_outflows (decimal string)."
                        },
                        "total_inflows": {
                          "type": "string",
                          "description": "Total income over the window (decimal string)."
                        },
                        "total_outflows": {
                          "type": "string",
                          "description": "Total expenses over the window (decimal string)."
                        },
                        "currency": {
                          "type": "string",
                          "example": "USD"
                        },
                        "time_series": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": ["period_start", "period_end", "inflows", "outflows", "net", "cumulative_net"],
                            "properties": {
                              "period_start": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "period_end": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "inflows": {
                                "type": "string"
                              },
                              "outflows": {
                                "type": "string"
                              },
                              "net": {
                                "type": "string"
                              },
                              "cumulative_net": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "data_quality": {
                          "$ref": "#/components/schemas/TransactionInsightDataQuality"
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/V1FinancialInsightMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/lite": {
      "get": {
        "operationId": "listAccountingPeriodsLite",
        "summary": "List periods (lite projection)",
        "description": "Returns accounting periods with a minimal projection — id, status, start_date, end_date, and legal_entity_id only. Optimized for UI period-picker dropdowns and any flow that needs every period quickly without the full close metadata. For the full record shape (close timestamps, close-by user, soft-close info), use `tools.accounting_periods.list_accounting_periods` instead. To list only periods scheduled for auto-close, use `tools.accounting_periods.list_scheduled_accounting_periods`.\n\n### Bounded result — not paginated\n\nThis is a single-shot helper hard-capped at 500 rows; it is deliberately not cursor-paginated (use it for pickers, not enumeration). When the cap is hit the response sets `has_more: true` and `capped_at: 500` — the extra rows are NOT retrievable here. To enumerate every period (e.g. an org with many legal entities × years), use the paginated `tools.accounting_periods.list_accounting_periods`, which supports keyset cursors.\n\nEach row carries legal_entity_id only, no entity name. To label the entity, join against list_legal_entities (GET /v1/legal-entities) with include_archived=true: a period can reference an archived legal entity, which the default legal-entities view hides. Omitting limit returns up to the 500-row cap.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1APLite"
                      }
                    },
                    "count": {
                      "type": "integer"
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "`true` when results were truncated at `capped_at`. There is no cursor here — fetch the full set via the paginated `list_accounting_periods` (or apply a narrower `legal_entity_id` filter)."
                    },
                    "capped_at": {
                      "type": "integer",
                      "description": "Hard cap on the number of rows this helper returns (500). When `has_more` is true, rows beyond the cap are not retrievable from this endpoint."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "ap_111",
                      "legal_entity_id": "le_456",
                      "name": "March 2026",
                      "status": "open",
                      "start_date": "2026-03-01T00:00:00.000Z",
                      "end_date": "2026-03-31T23:59:59.999Z"
                    }
                  ],
                  "count": 1
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/start-dates": {
      "get": {
        "operationId": "listAccountingPeriodStartDates",
        "summary": "List distinct period start dates",
        "x-mcp-no-entity-scope": true,
        "description": "Returns the unique set of `start_date_utc` values across all periods on the org, ascending. Used to populate \"month picker\" UI components.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity ID (`le_…`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "format": "date-time"
                      }
                    },
                    "count": {
                      "type": "integer"
                    },
                    "capped_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "data": ["2026-01-01T00:00:00.000Z", "2026-02-01T00:00:00.000Z"],
                  "count": 2
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/by-start-date": {
      "get": {
        "operationId": "listAccountingPeriodsByStartDate",
        "summary": "List periods on a specific start date",
        "description": "Returns every accounting period across all legal entities whose `start_date_utc` matches the supplied date. Useful for cross-entity reconciliation views that need to align periods on a common calendar boundary. For ID-based lookup use `tools.accounting_periods.get_accounting_period`; for filtered listing across a date range use `tools.accounting_periods.list_accounting_periods`.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "name": "start_date",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/AccountingPeriod"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/by-legal-entity": {
      "get": {
        "operationId": "listAccountingPeriodsByLegalEntity",
        "summary": "List periods on one legal entity",
        "description": "Returns every period grouped by legal entity. Each group is `{ legal_entity_id, periods: V1AccountingPeriod[] }`. Optional status filter narrows the periods after grouping.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter periods by status (lowercase).",
            "schema": {
              "type": "string",
              "enum": ["open", "closed", "soft_closed"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Periods grouped by legal entity.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "legal_entity_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "periods": {
                            "type": "array",
                            "items": {
                              "$ref": "#/components/schemas/AccountingPeriod"
                            }
                          }
                        }
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/scheduled": {
      "get": {
        "operationId": "listScheduledAccountingPeriods",
        "summary": "List per-legal-entity auto-close settings",
        "description": "Returns the per-legal-entity auto-close SETTINGS (`apcs_*` records) that drive the auto-close cron — one row per legal entity that has an auto-close schedule configured. These are configuration objects, NOT accounting-period (`ap_*`) documents, and NOT a subset of `tools.accounting_periods.list_accounting_periods`. To enumerate actual accounting periods use `tools.accounting_periods.list_accounting_periods` (or `tools.accounting_periods.list_accounting_periods_lite` for a minimal projection). The per-legal-entity close schedule is configured in the Entendre app UI and also read via `tools.accounting_periods.get_accounting_period_close_schedule`.",
        "tags": ["Accounting Periods"],
        "responses": {
          "200": {
            "description": "Auto-close settings, one per legal entity with a schedule configured.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1APCloseSettings"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/bulk-close": {
      "post": {
        "operationId": "bulkCloseAccountingPeriods",
        "summary": "Bulk close accounting periods",
        "description": "Asynchronously closes up to 50 periods. Periods are sorted by `start_date_utc` ascending so the oldest closes first (preserves the chronological-close invariant). Returns 202 with a `job_id` for `GET /jobs/{id}/status`.\n\nClosing a period is near-irreversible (it locks POSTED entries and carries balances forward). Send `dry_run: true` first to see exactly which submitted periods are Open and would be closed, then re-send with `confirm_count` set to the number of period ids you are submitting.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["period_ids"],
                "properties": {
                  "period_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Prefixed period IDs (`ap_…`)."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the actionable subset count that `dry_run` reports (`closable` for bulk-close, `reopenable` for bulk-reopen) — not the number of ids submitted — otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius, and lets a caller echo the dry_run count directly even when some submitted ids are duplicates or already in the target state."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns which submitted periods are Open (and would be closed) without enqueuing anything. Use to preview the exact scope before committing."
                  }
                }
              },
              "example": {
                "period_ids": ["ap_111", "ap_222", "ap_333"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "No-op: zero matching open periods.",
            "content": {
              "application/json": {
                "example": {
                  "data": {
                    "job_id": null,
                    "periods_count": 0
                  },
                  "message": "No open periods matched; nothing to close"
                }
              }
            }
          },
          "202": {
            "description": "Accepted; bulk close queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": "bulk-close-orgabc-1714400000000",
                    "periods_count": 3
                  },
                  "message": "Job queued; subscribe to SSE on job_id for progress"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/bulk-reopen": {
      "post": {
        "operationId": "bulkReopenAccountingPeriods",
        "summary": "Bulk reopen accounting periods",
        "description": "Asynchronously reopens up to 50 closed periods. Reverses the chronological constraint — newest reopens first.\n\nReopening a closed period diverges its snapshot reports. Send `dry_run: true` first to see which submitted periods are Closed (and would be reopened), then re-send with `confirm_count` set to the number of period ids you are submitting.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["period_ids"],
                "properties": {
                  "period_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 50,
                    "description": "Prefixed period IDs (`ap_…`)."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the actionable subset count that `dry_run` reports (`closable` for bulk-close, `reopenable` for bulk-reopen) — not the number of ids submitted — otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius, and lets a caller echo the dry_run count directly even when some submitted ids are duplicates or already in the target state."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns which submitted periods are Closed (and would be reopened) without enqueuing anything. Use to preview the exact scope before committing."
                  }
                }
              },
              "example": {
                "period_ids": ["ap_222", "ap_333"]
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted; job queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": "bulk-reopen-orgabc-1714400000000",
                    "periods_count": 2
                  },
                  "message": "Job queued; subscribe to SSE on job_id for progress"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/accounting-periods/schedule-close": {
      "get": {
        "operationId": "getAccountingPeriodCloseSchedule",
        "summary": "Get auto-close schedule for a legal entity",
        "description": "Returns the auto-close schedule configuration that the close cron uses for the organization. When called with `legal_entity_id`, returns the single config for that entity (404 if none). Without `legal_entity_id`, returns all configs across the org as an array envelope — that unset call is the intended org-wide read, so do NOT treat a missing `legal_entity_id` as an error to avoid. To create or modify a schedule, use the Entendre app UI. To list periods currently due for closure under existing schedules, use `tools.accounting_periods.list_scheduled_accounting_periods`.",
        "x-mcp-no-entity-scope": true,
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "name": "legal_entity_id",
            "in": "query",
            "required": false,
            "description": "Prefixed legal entity (`le_…`). When omitted, returns all configs for the org.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Single config (when legal_entity_id is supplied) or array of configs.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/V1APCloseSettings"
                        }
                      }
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1APCloseSettings"
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "upsertAccountingPeriodCloseSchedule",
        "summary": "Set auto-close schedule for a legal entity",
        "description": "Creates or updates the auto-close schedule the close cron uses for a given legal entity. Setting `day_of_month_to_close: 5` means the period for month _M_ closes automatically on day 5 of month _M+1_; setting `null` disables auto-close for that entity. To read the current schedule use `tools.accounting_periods.get_accounting_period_close_schedule`; to see which periods are currently scheduled to close under existing schedules use `tools.accounting_periods.list_scheduled_accounting_periods`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Accounting Periods"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["legal_entity_id", "close_date"],
                "properties": {
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Prefixed legal entity (`le_…`)."
                  },
                  "close_date": {
                    "type": "string",
                    "enum": [
                      "last_day_of_month",
                      "3_days_after_last_day_of_month",
                      "5_days_after_last_day_of_month",
                      "10_days_after_last_day_of_month"
                    ]
                  },
                  "timezone": {
                    "type": "string",
                    "description": "IANA timezone. Defaults to `UTC`."
                  },
                  "advanced_settings": {
                    "type": "object",
                    "properties": {
                      "sync_gl": {
                        "type": "boolean"
                      },
                      "run_revaluation": {
                        "type": "boolean"
                      }
                    }
                  }
                }
              },
              "example": {
                "legal_entity_id": "le_456def",
                "close_date": "last_day_of_month",
                "timezone": "America/New_York",
                "advanced_settings": {
                  "sync_gl": true,
                  "run_revaluation": false
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1APCloseSettings"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/status": {
      "get": {
        "operationId": "listGLConnections",
        "summary": "List all GL connections",
        "description": "Returns one `V1GLConnection` row per (provider, realm) pair — the `realm_id` is the provider's tenant identifier (QBO company ID, Xero tenant ID, NetSuite account number, DualEntry company ID). Read-only. Connecting a new GL provider is done out-of-band (OAuth / the Integrations UI), not through an agent tool.\n\nThis is GL-only and the right tool when the caller needs accounting-connection detail such as the realm/tenant id per provider. For a cross-provider \"is everything connected?\" summary that also spans Ramp, Stripe, Plaid, Finch, and Intuit (a `connected` boolean + count per provider), use `tools.integrations.list_integrations` instead; to list the individual corporate spend cards, use `tools.cards.list_cards`.",
        "tags": ["GL"],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1GLConnection"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/readiness": {
      "get": {
        "operationId": "getGLReadiness",
        "summary": "Check GL readiness for expense automation",
        "description": "Returns a readiness verdict for GL-driven expense automation (accruals, vendor sync, account mapping) plus the remaining blockers (`missing_configurations`). Read-only pre-flight check with no side effects.",
        "tags": ["GL"],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1GLReadiness"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/raw": {
      "get": {
        "operationId": "rawGLRead",
        "summary": "Read-only GL provider passthrough",
        "description": "Reads directly from the connected GL provider (QuickBooks / Xero / NetSuite), proxying a GET against a per-provider allowlist of read-only paths (QBO `/query`, `/reports/*`, `/companyinfo/{realm}`; Xero `/Accounts`, `/Journals`, \u2026; NetSuite `/record/v1/*`; DualEntry `/public/v2/*`; Campfire `/coa/api/*`). GET-only \u2014 never mutates. The provider payload is returned verbatim under `data`; result limits are caller-driven via `query`.\n\n**This is the tool for a connected-GL org's financial statements** \u2014 the external GL is the reconciled book of record for report-level P&L totals. Use the provider's native reports: QBO `/reports/BalanceSheet` (total assets / A/R / equity), `/reports/ProfitAndLoss` (revenue / expenses / net income), `/reports/GeneralLedger` (ledger detail), or a `/query` SQL (e.g. `SELECT * FROM Bill`). **Prefer this over the internal `reports.*` and `financial_insights.*` tools when the org has a connected GL**: those read Entendre's own ledger, which for some orgs is not reconciled to the external GL. Get `integration_type` + `realm_id` from `list_integrations`.\n\n### NOT for vendor-spend / merchant-spend rankings\n\n\"How much did we spend with vendor X\" / \"top vendors by spend\" / per-merchant / per-cardholder analysis is answered from the TRANSACTION ledger (`tools.transactions.list_transactions` \u2014 see the vendor-spend recipe), NOT from this tool. GL bill/purchase records are AP documents, not cash outflow, and miss card/bank/wallet spend entirely \u2014 the same applies to `tools.vault.list_qbobills` (bill records) and `tools.analytics.get_vendor_insights` (AP-table cadence analytics). Use this tool for vendor figures ONLY when the user explicitly asks for numbers \"direct from QuickBooks/Xero/NetSuite\" or \"raw from the GL\".\n\n### Provider paging and path rules\n\nFor QBO queries, the provider returns only a small default page and silently truncates: request `MAXRESULTS: 1000`, use `STARTPOSITION`, and continue while a page contains 1000 rows. Omit QBO's `/v3/company/{realmId}` prefix because this service adds it. DualEntry accepts only `/public/v2/{companies,accounts,customers,vendors,classifications,journal-entries}/`; its page size is fixed at 100, so paginate with `offset` rather than `limit`/`page_size`. Campfire paths have no trailing slash; `/coa/api/vendor` returns both customers and vendors, and `journal_entry` honors only `start_date` and `end_date` (not `date_from`, `since`, or `from`). DualEntry enforces a 30 requests/hour rate limit: avoid repeated or wide-fanout reads against DualEntry rather than issuing many calls.\n\n### When the provider report is empty \u2014 fall back, don't give up\n\nIf `/reports/ProfitAndLoss` (or another provider P&L / net-income report) returns NoReportData or all-zero because the accounting period is not closed or journal entries are unposted, do NOT answer \"unavailable\". Fall back to `tools.financial_insights.get_financial_insight_profit` (cash-basis net income over MCP) and STATE the basis you used. An empty accrual report is not proof of zero activity for the period.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": true,
            "description": "Provider tenant identifier (QBO company ID, Xero tenant ID, NetSuite account, DualEntry/Campfire realm).",
            "schema": {
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "path",
            "in": "query",
            "required": true,
            "description": "Provider-relative path to read (e.g. `/query`, `/Accounts`, `/coa/api/vendor`). Must match the provider's read-only allowlist; absolute URLs and path traversal are rejected.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "description": "JSON-encoded object of provider query params (e.g. `{\"q\":\"SELECT * FROM JournalEntry\",\"MAXRESULTS\":100}`). Must be a flat object of strings/numbers/booleans or arrays of those.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1GLRawResponse"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/config": {
      "get": {
        "operationId": "getGLConfig",
        "summary": "Get a GL integration config",
        "description": "Returns the GL integration configuration for one (provider, realm_id) pair — sync flags, real-time mode, rollup mode, and entity-mapping mode. For the full list across realms use `tools.gl.list_glconfigs`. Config create/update is not exposed as an MCP tool (human-owned integration control plane).",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, or `dualentry`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": true,
            "description": "Provider-issued tenant identifier (QBO realmId, Xero tenantId, NetSuite accountId).",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1GLConfig"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "upsertGLConfig",
        "summary": "Create or update a GL integration config",
        "description": "Upserts a GL integration configuration for one (provider, realm) pair — toggles `should_rolled_up` (aggregate JEs before push), `is_real_time` (push on every JE post), and `enable_realtime_import`. Creates the config if missing, updates in place if present. For read-only retrieval use `tools.gl.get_glconfig`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "should_rolled_up": {
                    "type": "boolean"
                  },
                  "rounding_adjustment_account_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "company_name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1GLConfig"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/configs": {
      "get": {
        "operationId": "listGLConfigs",
        "summary": "List inbound GL import configs",
        "description": "Returns every GL integration configuration for the supplied provider across all configured realms (tenants). This is the authoritative MCP read for inbound-import settings and freshness fields such as `is_real_time`, `enable_realtime_import`, `last_imported_date`, and `last_import_attempt`. Do not infer inbound import health from OAuth connection status, `last_activity_at`, or a recent outbound journal-entry sync; those are separate pipelines. Compare each config `realm_id` with active connections from `tools.integrations.list_integrations({ category: 'accounting' })` and cached company/realm entities from `tools.gl.list_glexternal_entities`. For a single (provider, realm) pair use `tools.gl.get_glconfig`.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, or `dualentry`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1GLConfig"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/external-accounts": {
      "get": {
        "operationId": "listGLExternalAccounts",
        "summary": "List external chart of accounts",
        "description": "Returns Entendre's cached mirror of the GL provider's chart of accounts — provider account IDs, realm/tenant IDs, codes, types, active state, and parent relationships. Supply `limit` (1–25) to use cursor pagination, passing only the returned opaque `next_cursor` while `has_more` is true; do not use `page` or `pageSize`. The MCP tool supplies a transport-safe limit of 25 automatically. Omitting both `limit` and `cursor` preserves the legacy direct-v1 full-list response. Set `include_count=true` to receive the exact `total_count`; legacy `count` remains the complete filtered-account count for direct v1 compatibility. These are account rows, not provider-company/realm entities. When `realm_id` is omitted, group the returned rows by their own `realm_id`; do not assign every row to the sole active connection by inference. Cached rows can be stale and are not equivalent to a live provider query. To refresh from the provider call `tools.gl.refresh_glexternal_accounts` only with explicit user approval because it mutates the cache. For cached provider-company entities use `tools.gl.list_glexternal_entities`; for Entendre-side ledger accounts use `tools.ledger_accounts.list_ledger_accounts`.",
        "x-mcp-typed-response": true,
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, `dualentry`, or `campfire`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": false,
            "description": "Provider-issued tenant identifier (QBO realmId, Xero tenantId, NetSuite accountId, DualEntry company, or Campfire workspace).",
            "schema": { "type": "string", "minLength": 1 }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Opt into cursor pagination with a page size from 1–25. The MCP wrapper defaults this to 25; omitting both limit and cursor preserves the direct-v1 full-list response.",
            "x-mcp-wrapper-default": 25,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 25
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque cursor from the previous response. It is bound to the authenticated organization, endpoint, integration type, realm filter, and sort; pass it unchanged and do not use page-number pagination.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "include_count",
            "in": "query",
            "required": false,
            "description": "When true, include the exact total_count for the filtered cached-account set.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1ExternalAccount"
                      }
                    },
                    "count": {
                      "type": "integer",
                      "description": "Complete filtered-account count, retained for direct-v1 compatibility."
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Exact filtered count when include_count=true."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/external-entities": {
      "get": {
        "operationId": "listGLExternalEntities",
        "summary": "List cached external company/realm entities",
        "description": "Returns Entendre's cached provider-company / tenant entities, including the provider-issued `realm_id` (for example a QBO company realm, Xero tenant, NetSuite account/subsidiary, DualEntry company, or Campfire entity). These rows are NOT QBO customers, vendors, or chart-of-accounts records. Use them to detect cached realms that no longer have a matching active authentication connection: compare their `realm_id` values with `tools.integrations.list_integrations({ category: 'accounting' })`. One active OAuth connection does not prove there is no orphaned cached entity. For cached chart-of-accounts rows use `tools.gl.list_glexternal_accounts`; for provider customers use `tools.gl.list_glcustomers`.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, `dualentry`, or `campfire`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": false,
            "description": "Provider-issued tenant identifier (QBO realmId, Xero tenantId, NetSuite accountId).",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1ExternalEntity"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/account-mappings": {
      "get": {
        "operationId": "listGLAccountMappings",
        "summary": "List ledger account mappings",
        "description": "Lists mappings from Entendre ledger accounts to external QuickBooks, Xero, NetSuite, DualEntry, or Campfire accounts for one provider realm. Join each row's `internal_ledger_account_id` to the exact `id` returned by `/ledger-accounts`; account names are not stable mapping keys. Use `limit` and the opaque `next_cursor` for pagination, and set `include_count=true` when an exact filtered total is needed.",
        "x-mcp-description": "Returns the current mappings from Entendre ledger accounts (`lac_…` IDs) to their external GL counterparts (QuickBooks, Xero, NetSuite, DualEntry, or Campfire accounts) for one explicit realm. Each row's Entendre-side join key is `internal_ledger_account_id`: compare it exactly with the `id` returned by `/ledger-accounts`. Do not use the mapping row's own `id`, `external_ledger_account_id`, or an account name for mapping coverage.\nThe MCP success result has two envelope layers: the tool returns `{ ok: true, data: apiPage }`, and the mapping rows are in `apiPage.data` (equivalently `result.data.data`). Read `has_more`, `next_cursor`, `total_count`, and legacy `count` from `apiPage`, not from the outer tool result. Always verify `result.ok === true`, that `result.data` exists, and that `result.data.data` is an array before reading page fields.\nSupply `limit` (1–25) to use cursor pagination, passing only the returned opaque `next_cursor` while `has_more` is true; do not use `page` or `pageSize`. Reject a missing or repeated continuation cursor instead of dereferencing an undefined page or looping. The MCP tool supplies a transport-safe limit of 25 automatically. Omitting both `limit` and `cursor` preserves the legacy direct-v1 full-list response. Set `include_count=true` to receive the exact `total_count`; legacy `count` remains the complete mapping count for direct v1 compatibility.\nDefensive MCP pagination pattern:\n```js\nconst rows = [];\nconst seen = new Set();\nlet cursor;\nwhile (true) {\n  const result = await tools.gl.list_glaccount_mappings({ integration_type, realm_id, limit: 25, include_count: true, ...(cursor ? { cursor } : {}) });\n  if (!result || result.ok !== true) return result?.error ?? { code: 'missing_tool_result' };\n  const page = result.data;\n  if (!page || !Array.isArray(page.data)) return { code: 'unexpected_response_shape' };\n  rows.push(...page.data);\n  if (page.has_more !== true) break;\n  if (typeof page.next_cursor !== 'string' || !page.next_cursor || seen.has(page.next_cursor)) return { code: 'invalid_next_cursor' };\n  seen.add(page.next_cursor);\n  cursor = page.next_cursor;\n}\n```\nFor a realm audit, build the union of realm IDs found in active connections, GL configs, and cached external company entities, then exhaust this tool's cursors once per realm — querying only the active realm can hide orphaned-realm mappings. Absence of a mapping row means unmapped for that realm; it does not mean the ledger account is unused. To find which Entendre ledger accounts map to a SPECIFIC external (e.g. QuickBooks) account — the reverse of coverage — first resolve that external account with `tools.gl.list_glexternal_accounts` (match its `name` or `account_number` to read that row's `external_id`), then filter these mapping rows where `external_ledger_account_id` equals that `external_id`. A mapping row's `external_ledger_account_id` is the SAME key as `list_glexternal_accounts.external_id` — both are that column verbatim, and for QuickBooks it is the provider's own Account `Id`, so the join is exact rather than approximate. Resolve it through `tools.gl.list_glexternal_accounts` anyway instead of reusing an identifier lifted from somewhere else: an Entendre `lac_…` id, an account name or number, or a provider id belonging to a different object (a transaction or JE-line id, or an account id from another realm) all match zero rows here and would read as a false negative. After filtering by a resolved `external_id`, zero matching rows is a CONFIRMED negative — no ledger account maps to that external account — not a tooling limitation; report it as confirmed rather than as an inability to join. Read-only. To apply differential changes use `tools.gl.patch_glaccount_mappings`.",
        "x-mcp-typed-response": true,
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, `dualentry`, or `campfire`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": true,
            "description": "Provider-issued tenant identifier (QBO realmId, Xero tenantId, NetSuite accountId, DualEntry company, or Campfire workspace).",
            "schema": { "type": "string", "minLength": 1 }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Opt into cursor pagination with a page size from 1–25. The MCP wrapper defaults this to 25; omitting both limit and cursor preserves the direct-v1 full-list response.",
            "x-mcp-wrapper-default": 25,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 25
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque cursor from the previous response. It is bound to the authenticated organization, endpoint, integration type, realm filter, and sort; pass it unchanged and do not use page-number pagination.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "include_count",
            "in": "query",
            "required": false,
            "description": "When true, include the exact total_count for the filtered realm.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "count"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1AccountMapping"
                      }
                    },
                    "count": {
                      "type": "integer",
                      "description": "Complete mapping count, retained for direct-v1 compatibility."
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "Present on cursor-paginated responses; omitted by the legacy full-list response."
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Present on cursor-paginated responses; omitted by the legacy full-list response."
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Exact filtered count when include_count=true."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "bulkUpsertGLAccountMappings",
        "summary": "Bulk upsert account mappings",
        "description": "Bulk upsert. Chunks internally at 100 mappings, but the entire call runs in a SINGLE transaction (all-or-nothing — a failure in any chunk rolls back the whole batch); max 500 per call. Returns the upserted documents in input order.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id", "mappings"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "mappings": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": {
                      "$ref": "#/components/schemas/V1AccountMappingPair"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1AccountMapping"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "patchGLAccountMappings",
        "summary": "Differential update of account mappings",
        "description": "Patches the ledger-account ↔ GL-account mapping table with a differential `{ added, updated, removed }` payload — all three sets land atomically inside one transaction. At least one bucket must be non-empty. For full-state replacement use `tools.gl.bulk_upsert_glaccount_mappings`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "added": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/V1AccountMappingPair"
                    }
                  },
                  "updated": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/V1AccountMappingPair"
                    }
                  },
                  "removed": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "Internal ledger account ID (`lac_…`); the corresponding mapping is soft-detached."
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Differential applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1AccountMapping"
                      }
                    },
                    "count": {
                      "type": "integer"
                    },
                    "summary": {
                      "type": "object",
                      "properties": {
                        "added": {
                          "type": "integer"
                        },
                        "updated": {
                          "type": "integer"
                        },
                        "removed": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/entity-mappings": {
      "get": {
        "operationId": "listGLEntityMappings",
        "summary": "List legal entity mappings",
        "description": "Returns the current mappings from Entendre legal entities (`le_…` IDs) to their external GL counterparts (QBO classes, Xero tracking categories, NetSuite subsidiaries, DualEntry companies, or Campfire entities). `external_legal_entity_id` is the provider-issued id returned by `tools.gl.list_glexternal_entities`, never Entendre's internal ExternalLegalEntities pointer. Read-only. To apply differential changes use `tools.gl.patch_glentity_mappings`; for full-state replacement use `tools.gl.bulk_upsert_glentity_mappings`.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, `dualentry`, or `campfire`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": true,
            "description": "Provider-issued tenant identifier (QBO realmId, Xero tenantId, NetSuite accountId).",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1EntityMapping"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "bulkUpsertGLEntityMappings",
        "summary": "Bulk upsert entity mappings",
        "description": "Creates or updates the mapping between Entendre legal entities and their external general-ledger counterparts (QuickBooks classes, Xero tracking categories, NetSuite subsidiaries, DualEntry companies, or Campfire entities) in one transactional batch. Use this for the initial sync of an entire org or after a big entity restructure; for incremental adds / removes use `tools.gl.patch_glentity_mappings` instead, which accepts a `{ added, updated, removed }` differential. The whole chunk lands atomically — if any entry fails validation, the entire bulk rejects. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id", "mappings"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "mappings": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "$ref": "#/components/schemas/V1EntityMappingPair"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1EntityMapping"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "patchGLEntityMappings",
        "summary": "Differential update of entity mappings",
        "description": "Applies a differential update to the legal-entity ↔ GL entity mapping table — accepts an `{ added, updated, removed }` payload and applies all three sets atomically in one transaction. Use this for incremental maintenance after the initial bulk upsert; for full-state replacement use `tools.gl.bulk_upsert_glentity_mappings` instead. The whole patch lands atomically — partial failure rolls back every section, including the `removed` deletions. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "added": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/V1EntityMappingPair"
                    }
                  },
                  "updated": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/V1EntityMappingPair"
                    }
                  },
                  "removed": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "Internal legal entity ID (`le_…`); the corresponding mapping is soft-detached."
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Differential applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1EntityMapping"
                      }
                    },
                    "count": {
                      "type": "integer"
                    },
                    "summary": {
                      "type": "object",
                      "properties": {
                        "added": {
                          "type": "integer"
                        },
                        "updated": {
                          "type": "integer"
                        },
                        "removed": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/vendors": {
      "get": {
        "operationId": "listGLVendors",
        "summary": "List vendors from the GL provider",
        "description": "Returns vendors from the live GL provider — e.g. QBO Vendor objects or Xero Supplier objects. Supported on all connected GL providers (QuickBooks, Xero, NetSuite, DualEntry, Campfire). Live call, subject to provider rate limits (slow on first run). For customers (the AR side) use `tools.gl.list_glcustomers`. Before a multi-realm audit, call `tools.integrations.list_integrations({ category: 'accounting' })`: each connection's `external_id` is its realm ID. Select the provider first, then pass that provider's exact `realm_id`; never default to the first connection. Omitting `realm_id` remains valid when exactly one connection exists for the chosen provider, but returns 409 with available realm IDs when several exist. For an audit, call only connections whose status is `active`; report expired or unavailable connections separately rather than as an empty live vendor list, and do not retry a failed live call. A direct, explicit request still preserves the provider's normal token-refresh behavior. For the internal Vault vendor directory (names/ids + aggregate `files_count`/`total_bills_amount`, not a live call) use `tools.vault.list_vault_vendors`; for billing-cadence / spend analytics from the AP bill table use `tools.analytics.get_vendor_insights`.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, `dualentry`, or `campfire`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": false,
            "description": "Provider-issued realm or tenant ID (for example QBO company realm, Xero tenant, NetSuite account, DualEntry company, or Campfire workspace). Get it from the exact accounting connection's `external_id` in `tools.integrations.list_integrations({ category: 'accounting' })`. Required when the chosen provider has multiple connections; optional for a single connection.",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["id", "display_name"],
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "display_name": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Multiple connections exist for the requested provider and `realm_id` was omitted. The error includes `availableRealmIds`; select one explicitly instead of retrying without a realm.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/customers": {
      "get": {
        "operationId": "listGLCustomers",
        "summary": "List customers from the GL provider",
        "description": "Returns customers from the live GL provider — e.g. QBO Customer objects or Xero Contact objects. Supported on all connected GL providers (QuickBooks, Xero, NetSuite, DualEntry, Campfire). Live call subject to provider rate limits. For vendors (the other side of AP / AR) use `tools.gl.list_glvendors`. Before a multi-realm audit, call `tools.integrations.list_integrations({ category: 'accounting' })`: each connection's `external_id` is its realm ID. Select the provider first, then pass that provider's exact `realm_id`; never default to the first connection. Omitting `realm_id` remains valid when exactly one connection exists for the chosen provider, but returns 409 with available realm IDs when several exist. For an audit, call only connections whose status is `active`; report expired or unavailable connections separately rather than as an empty live customer list, and do not retry a failed live call. A direct, explicit request still preserves the provider's normal token-refresh behavior.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": true,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, `dualentry`, or `campfire`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
            }
          },
          {
            "name": "realm_id",
            "in": "query",
            "required": false,
            "description": "Provider-issued realm or tenant ID (for example QBO company realm, Xero tenant, NetSuite account, DualEntry company, or Campfire workspace). Get it from the exact accounting connection's `external_id` in `tools.integrations.list_integrations({ category: 'accounting' })`. Required when the chosen provider has multiple connections; optional for a single connection.",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["id", "display_name"],
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "display_name": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Multiple connections exist for the requested provider and `realm_id` was omitted. The error includes `availableRealmIds`; select one explicitly instead of retrying without a realm.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl/connect": {
      "post": {
        "operationId": "connectGL",
        "summary": "Connect a GL provider",
        "description": "Connects a general-ledger provider to the organization. The request body is discriminated on `provider`. OAuth providers (`quickbooks`, `xero`, `netsuite`) accept `redirect_url` or `subdomain` and return a `redirect_url` (HTTP 200) that the human user must open in a browser to grant access. Server-only consumers cannot complete the OAuth flow today. NetSuite additionally requires a pre-provisioned org config and ignores any legacy `account_id`. API-key providers (`dualentry`, `campfire`) accept `api_key`, validate it against the provider, encrypt it at rest, and return the new connection record (HTTP 201). Use `tools.integrations.list_integrations` (filter `category=accounting`) to enumerate existing connections. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/V1GLConnectQuickBooks"
                  },
                  {
                    "$ref": "#/components/schemas/V1GLConnectXero"
                  },
                  {
                    "$ref": "#/components/schemas/V1GLConnectNetSuite"
                  },
                  {
                    "$ref": "#/components/schemas/V1GLConnectDualEntry"
                  },
                  {
                    "$ref": "#/components/schemas/V1GLConnectCampfire"
                  }
                ],
                "discriminator": {
                  "propertyName": "provider",
                  "mapping": {
                    "quickbooks": "#/components/schemas/V1GLConnectQuickBooks",
                    "xero": "#/components/schemas/V1GLConnectXero",
                    "netsuite": "#/components/schemas/V1GLConnectNetSuite",
                    "dualentry": "#/components/schemas/V1GLConnectDualEntry",
                    "campfire": "#/components/schemas/V1GLConnectCampfire"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OAuth authorize URL (provider = quickbooks | xero | netsuite). Open it in a browser to grant access.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "redirect_url": {
                          "type": "string",
                          "format": "uri"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "Connection created (provider = dualentry | campfire).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1GLConnection"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error (API-key validation failed upstream).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/email-ingestion/connect": {
      "post": {
        "operationId": "connectGmail",
        "summary": "Connect a Gmail inbox",
        "description": "Connect a Gmail inbox for email ingestion by starting the Google OAuth flow (parity with `/gl/connect` for QuickBooks/Xero). Returns an `authorize_url` (HTTP 200) that the human user must open in a browser to grant read-only Gmail access — server-only callers cannot complete Google's consent screen. After consent the connection is created server-side; poll `GET /email-ingestion/connections` for it. Pass `connection_id` to re-authorize an existing connection that is in `error` state. `return_url` must be an Entendre-allowlisted URL (where the browser lands after consent). Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Email Ingestion"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["return_url"],
                "properties": {
                  "return_url": {
                    "type": "string",
                    "description": "Entendre-allowlisted URL the browser returns to after consent."
                  },
                  "connection_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Optional: re-authorize an existing connection that is in `error` state."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OAuth authorize URL. Open it in a browser to grant access.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "authorize_url": {
                          "type": "string",
                          "format": "uri"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The org is at its connection limit, or the connection is not in a re-authorizable state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/email-ingestion/connections": {
      "get": {
        "operationId": "listEmailConnections",
        "summary": "List connected email inboxes",
        "description": "Lists the organization's connected email inboxes (metadata only — tokens are never returned). Optional `status` filter (`active`, `error`) — disconnected inboxes are soft-deleted and never listed. The response shape is `{ data: Array<{ connection_id, provider, email_address, display_name, status, connected_at, last_synced_at, created_at }> }`.",
        "tags": ["Email Ingestion"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["active", "error"]
            },
            "description": "Filter by connection status."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "description": "Maximum number of connections to return (1–100, default 50)."
          }
        ],
        "responses": {
          "200": {
            "description": "The org's email connections.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "connection_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "provider": {
                            "type": "string"
                          },
                          "email_address": {
                            "type": "string"
                          },
                          "display_name": {
                            "type": "string",
                            "nullable": true
                          },
                          "status": {
                            "type": "string",
                            "enum": ["active", "error"]
                          },
                          "connected_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "last_synced_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/email-ingestion/connections/{connectionId}": {
      "delete": {
        "operationId": "disconnectEmailConnection",
        "summary": "Disconnect an email inbox",
        "description": "Disconnects a connected inbox: revokes the Google grant, stops the Gmail watch, soft-deletes the connection, and cancels in-flight ingestion. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["Email Ingestion"],
        "parameters": [
          {
            "name": "connectionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The connection id (from `GET /email-ingestion/connections`)."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Disconnected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "connection_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "disconnected": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/disconnect": {
      "post": {
        "operationId": "disconnectGL",
        "summary": "Disconnect a GL integration",
        "description": "Revokes the OAuth token and soft-deletes the connection. Existing synced JEs remain — use `/gl-sync/unsync` to remove them.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
                  },
                  "realm_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Disconnected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "disconnected": {
                          "type": "boolean"
                        },
                        "integration_type": {
                          "type": "string"
                        },
                        "realm_id": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/refresh-external-accounts": {
      "post": {
        "operationId": "refreshGLExternalAccounts",
        "summary": "Refresh cached external chart",
        "description": "Triggers a fresh fetch of the GL provider's chart of accounts and updates the cached `V1ExternalAccount` records. Use this after the customer has restructured their CoA in the provider UI so subsequent mapping picks pull from current data. Returns 202; subscribe to `gl.external_accounts_refreshed` webhooks for completion.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
                  },
                  "realm_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Synchronous refresh completed (QBO/Xero/DualEntry/Campfire).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "integration_type": {
                          "type": "string"
                        },
                        "realm_id": {
                          "type": "string"
                        },
                        "refreshed": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "NetSuite path: refresh queued (long-running).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "integration_type": {
                          "type": "string"
                        },
                        "realm_id": {
                          "type": "string"
                        },
                        "queued": {
                          "type": "boolean"
                        }
                      }
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/import": {
      "post": {
        "operationId": "triggerGLImport",
        "summary": "Trigger a historical import",
        "description": "Triggers a historical import job that pulls past transactions and account state from the GL provider into Entendre. Returns 202 with a `glc_…`-prefixed `job_id`. Poll `tools.jobs.get_job_status` or subscribe to `gl.import_completed` / `gl.import_failed` webhooks for completion. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted; import queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": "glc_507f1f77bcf86cd799439011",
                    "integration_type": "quickbooks",
                    "realm_id": "9341452123456789"
                  },
                  "message": "Job queued; subscribe to SSE on job_id for progress"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/resolve-types": {
      "post": {
        "operationId": "resolveGLQboTypes",
        "summary": "Resolve QBO transaction types for journal entries",
        "description": "Resolves the QBO transaction type (Purchase, Deposit, BillPayment, Transfer, JournalEntry, etc.) each input journal entry should sync as — encodes the routing logic that QBO's API requires per transaction shape. Max 200 IDs per call. Read-only — does NOT trigger a sync; it just computes the type each entry would use. For the actual sync use `tools.gl.trigger_glsync`.",
        "x-mcp-read-only": true,
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["journal_entry_ids"],
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 200
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "journal_entry_id": {
                            "type": "string"
                          },
                          "qbo_type": {
                            "type": "string"
                          },
                          "confidence": {
                            "type": "string",
                            "enum": ["HIGH", "MEDIUM", "LOW"]
                          },
                          "reason": {
                            "type": "string"
                          },
                          "account_types": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "description": "External account types referenced by the JE (e.g., Bank, Expense, Credit Card)."
                          }
                        }
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/resolve-vendors": {
      "post": {
        "operationId": "resolveGLVendors",
        "summary": "Resolve vendor/customer references before sync",
        "description": "Resolves vendor / customer references for the supplied journal entries via token-overlap matching against QBO vendors / customers, auto-creating QBO entities when no plausible match is found. Slow (up to 1-2s per JE on first call, faster afterward via cache). Max 100 IDs per call. For a QuickBooks target, resolve references after classification and before sync: AP entries require their VendorRef and AR entries require their CustomerRef. This is a correctness prerequisite for those entries, not merely a latency optimization. Other providers short-circuit as skipped.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["journal_entry_ids"],
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 100
                  },
                  "gl_type": {
                    "type": "string",
                    "enum": ["QUICKBOOKS", "XERO", "NETSUITE", "DUALENTRY", "CAMPFIRE"],
                    "description": "Optional target GL type. When omitted, auto-resolves (single-GL orgs auto-use; multi-GL orgs return 400 listing the connected types). Pre-resolution is meaningful only for QuickBooks targets; other providers short-circuit as skipped."
                  },
                  "realm_id": {
                    "type": "string",
                    "description": "Optional provider tenant/realm id. Required when the org has more than one connection of the same `gl_type`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "results": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "journal_entry_id": {
                                "type": "string"
                              },
                              "journal_sequence_number": {
                                "type": "string",
                                "nullable": true
                              },
                              "qbo_type": {
                                "type": "string"
                              },
                              "entity_type": {
                                "type": "string",
                                "enum": ["vendor", "customer"]
                              },
                              "resolved": {
                                "type": "boolean"
                              },
                              "entity_id": {
                                "type": "string",
                                "nullable": true
                              },
                              "entity_name": {
                                "type": "string",
                                "nullable": true
                              },
                              "resolved_by": {
                                "type": "string",
                                "nullable": true
                              },
                              "match_score": {
                                "type": "number",
                                "nullable": true
                              },
                              "matched_tokens": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "nullable": true
                              },
                              "error": {
                                "type": "string",
                                "nullable": true
                              }
                            }
                          }
                        },
                        "stats": {
                          "type": "object",
                          "properties": {
                            "total": {
                              "type": "integer"
                            },
                            "vendors_resolved": {
                              "type": "integer"
                            },
                            "customers_resolved": {
                              "type": "integer"
                            },
                            "auto_created": {
                              "type": "integer"
                            },
                            "failed": {
                              "type": "integer"
                            }
                          }
                        },
                        "success": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/search-records": {
      "post": {
        "operationId": "searchGLRecords",
        "summary": "Search records in the connected GL provider",
        "description": "Searches the org's connected GL (QuickBooks, Xero, NetSuite, DualEntry, Campfire) for records matching the supplied filters — an ERP-agnostic read that makes live provider calls and does NOT mutate anything. If the org has more than one GL connected, `gl_type` is required; if it has more than one realm of that type, `realm_id` is required (the 400 lists the available realms). When `date_from`/`date_to` are omitted, the last 90 days are searched. Xero returns POSTED entries only; `record_types` applies to QuickBooks only. Returns a 501 when a Campfire date window contains more than ~10k entries (narrow the range). When no GL is connected, returns an empty result. NOT a vendor-spend source — for \"how much did we spend with vendor X\" / \"top vendors by spend\" use `tools.transactions.list_transactions` (see the vendor-spend recipe); GL bill/purchase records are AP documents, not cash outflow.\n\n### 0 results is not proof the GL is broken\n\nA 0-result response is normal — do NOT conclude the GL is unavailable or read-only. Before giving up, widen the search: omit `date_from`/`date_to` (a default ~90-day window applies) or push the range further back, then drop vendor/keyword filters. Only after a widened, unfiltered search still returns nothing should you report 'not found', and say what window/filters you searched. (Mirrors copilot search-gl.ts:12,225.) This is also the QuickBooks accounts-receivable (AR) customer-invoice source: pass `record_types: ['Invoice']` to list or search AR customer invoices, and set `vendor` to a customer name to scope to one customer. It is the AR counterpart to `tools.vault.list_qbobills` (QuickBooks AP vendor bills).\n\nAn Invoice row here carries only `id`, `type`, `date`, `amount`, `vendor`, `account`, `accountCode` and `memo` — there is NO balance and NO status field, so these rows CANNOT be split into open vs paid. Present them as the customer's invoices in the window searched, never as their outstanding/open invoices or an AR balance. For open-vs-paid, aging, or 'what does this customer still owe', use `tools.cash_applications.search_cash_application_invoices` (Stripe AR — carries `status`) or `tools.gl.raw_glread` with a provider query that selects `Balance`.\n\nChoosing between the two AR readers is decidable, in this order: if the org has Stripe AR connected, start at `tools.cash_applications.search_cash_application_invoices`. Use this tool with `record_types: ['Invoice']` when the org has no Stripe AR connection, or when that Stripe search resolved the customer and still returned no rows on a QuickBooks-connected org — a QBO-only org's customer invoices exist ONLY here, so reporting none without checking this tool is wrong. When scoping to a customer, prefer omitting `date_from`/`date_to` (uses the ~90-day default) or a targeted window over a very wide range — mirror how you would ask for recent activity; if a customer search is empty, omit or narrow the window and retry before concluding none.",
        "x-mcp-read-only": true,
        "tags": ["GL"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "gl_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"],
                    "description": "Required only when the org has more than one GL connected."
                  },
                  "realm_id": {
                    "type": "string",
                    "description": "Provider tenant id (QBO companyId, Xero tenantId, NetSuite accountId). Required when the org has more than one realm of `gl_type`."
                  },
                  "vendor": {
                    "type": "string",
                    "description": "Substring match over vendor/memo. When `record_types` is `['Invoice']`, this matches the customer name (QuickBooks `CustomerRef`) — set it to a customer to return that customer's invoices."
                  },
                  "keyword": {
                    "type": "string",
                    "description": "Substring match over vendor + account + memo."
                  },
                  "amount_min": {
                    "type": "number"
                  },
                  "amount_max": {
                    "type": "number"
                  },
                  "date_from": {
                    "type": "string",
                    "description": "Inclusive lower bound, YYYY-MM-DD. Defaults to 90 days ago.",
                    "examples": ["2026-01-01"]
                  },
                  "date_to": {
                    "type": "string",
                    "description": "Inclusive upper bound, YYYY-MM-DD. Defaults to today.",
                    "examples": ["2026-03-31"]
                  },
                  "record_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "Purchase",
                        "Deposit",
                        "Payment",
                        "Transfer",
                        "JournalEntry",
                        "BillPayment",
                        "Bill",
                        "Invoice"
                      ]
                    },
                    "description": "QuickBooks only — restricts which transaction types are queried. `Invoice` returns accounts-receivable customer invoices (AR); `Bill` returns accounts-payable vendor bills (AP)."
                  },
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 200,
                    "default": 20
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Search results (or an empty result when no GL is connected).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "records": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string",
                                "description": "Provider-native record id."
                              },
                              "type": {
                                "type": "string"
                              },
                              "date": {
                                "type": "string"
                              },
                              "amount": {
                                "type": "number"
                              },
                              "vendor": {
                                "type": "string"
                              },
                              "account": {
                                "type": "string"
                              },
                              "account_code": {
                                "type": "string"
                              },
                              "memo": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "total_found": {
                          "type": "integer",
                          "description": "Total matched records before the `limit` slice."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "501": {
            "description": "The provider cannot satisfy the query safely (e.g. a Campfire date window with more than ~10k entries) — narrow the date range and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/synced-count": {
      "post": {
        "operationId": "countGLSyncedRecords",
        "summary": "Count Entendre-synced rows remaining in the connected GL (post-op read-back)",
        "description": "Read-back verification for GL-mutating bulk ops. Re-reads the org's connected GL over [date_from, date_to] and counts the rows that STILL carry an Entendre sync marker in their memo/narration — i.e. the exact inverse of /search-records (which filters those out). Used after a bulk unsync/void to confirm the rows actually left the external GL before declaring the cleanup done; Entendre-side counts are not sufficient proof. Live provider read, no mutation. Multi-GL orgs require `gl_type`; multi-realm require `realm_id`. When no GL is connected, returns synced_rows_remaining:0.",
        "x-mcp-read-only": true,
        "tags": ["GL"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["date_from", "date_to"],
                "properties": {
                  "gl_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"],
                    "description": "Required only when the org has more than one GL connected."
                  },
                  "realm_id": {
                    "type": "string",
                    "description": "Provider tenant id. Required when the org has more than one realm of `gl_type`."
                  },
                  "date_from": {
                    "type": "string",
                    "description": "Inclusive lower bound of the affected window, YYYY-MM-DD.",
                    "examples": ["2026-06-05"]
                  },
                  "date_to": {
                    "type": "string",
                    "description": "Inclusive upper bound, YYYY-MM-DD.",
                    "examples": ["2026-06-07"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The remaining-synced count over the window (or 0 when no GL is connected).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "gl_type": {
                          "type": ["string", "null"],
                          "description": "Resolved GL type, or null when none connected."
                        },
                        "synced_rows_remaining": {
                          "type": "integer",
                          "description": "Rows in the window whose memo still bears an Entendre sync marker."
                        },
                        "sample_remaining": {
                          "type": "array",
                          "description": "Up to 10 of the remaining rows, for a human-readable report.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "external_id": { "type": "string" },
                              "date": { "type": "string" },
                              "amount": { "type": "number" },
                              "vendor": { "type": "string" },
                              "memo": { "type": "string" }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/check-duplicate": {
      "post": {
        "operationId": "checkGLDuplicate",
        "summary": "Check whether a GL entry already covers this amount/date/vendor",
        "description": "Determines whether a GL entry already covers a prospective amount/date/vendor (a forward-looking bank-feed duplicate probe) against the org's connected GL (QuickBooks, Xero, NetSuite, DualEntry, Campfire). Intended as a pre-sync guard before posting a journal entry. Matching is amount ±`amount_tolerance_cents` cents (default 2, so ±$0.02) + date ±`date_tolerance_days` days (default 1) + fuzzy vendor; `match_confidence` is `high` when the vendor also matches, else `low`. This is a live provider read and does NOT mutate anything. If the org has more than one GL connected, `gl_type` is required; if it has more than one realm of that type, `realm_id` is required (the 400 lists the available realms). Xero scans POSTED entries only. Returns a 501 when a date window is too large to scan safely (Campfire/NetSuite >10k entries, Xero >5k) — narrow the range. When no GL is connected, returns `{ duplicate_found: false }`. This is NOT POST /gl-sync/duplicates/scan, which retrospectively scans already-synced entries. Acting on the result: match_confidence high means do NOT sync, the GL already covers it; match_confidence low (amount and date matched, vendor did not) means confirm with the user before deduping. A failed or errored check is NOT the same as duplicate_found false: treat any error (a 400/501/502 or a network/auth failure) as a hard stop and do NOT sync until it is resolved and the check re-run. Vendor evidence is per-GL: only NetSuite exposes a per-line entity name, so a low-confidence result is normal and expected on Xero, DualEntry, and Campfire, which have no per-line VendorRef.",
        "x-mcp-read-only": true,
        "tags": ["GL"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["amount", "date"],
                "properties": {
                  "amount": {
                    "type": "number",
                    "description": "Transaction amount to probe for (absolute value)."
                  },
                  "date": {
                    "type": "string",
                    "description": "Transaction date, YYYY-MM-DD.",
                    "examples": ["2026-04-15"]
                  },
                  "vendor": {
                    "type": "string",
                    "description": "Vendor/payee name for additional matching precision (fuzzy)."
                  },
                  "gl_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"],
                    "description": "Required only when the org has more than one GL connected."
                  },
                  "realm_id": {
                    "type": "string",
                    "description": "Provider tenant id (QBO companyId, Xero tenantId, NetSuite accountId). Required when the org has more than one realm of `gl_type`."
                  },
                  "date_tolerance_days": {
                    "type": "number",
                    "default": 1,
                    "description": "Date match window in days (±)."
                  },
                  "amount_tolerance_cents": {
                    "type": "number",
                    "default": 2,
                    "description": "Amount match tolerance in cents (±)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Duplicate-check result (or `{ duplicate_found: false }` when no GL is connected).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "duplicate_found": {
                          "type": "boolean"
                        },
                        "existing_entry_id": {
                          "type": "string",
                          "description": "Provider-native id of the matching entry (NOT an Entendre id). Present only when `duplicate_found` is true."
                        },
                        "existing_entry_type": {
                          "type": "string"
                        },
                        "existing_vendor": {
                          "type": "string"
                        },
                        "existing_date": {
                          "type": "string"
                        },
                        "existing_amount": {
                          "type": "number"
                        },
                        "match_confidence": {
                          "type": "string",
                          "enum": ["high", "low"]
                        },
                        "gl_type": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "501": {
            "description": "The provider cannot scan the date window safely (too many entries) — narrow the date range and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-sync/sync": {
      "post": {
        "operationId": "triggerGLSync",
        "summary": "Sync one accounting period to the GL provider",
        "description": "Triggers a sync of one accounting period's journal entries to the GL provider — queues a `SYNC_JOURNAL_ENTRIES` BullMQ job. If `journal_entry_ids` is omitted (or `[]`), every JE in the period is synced; callers that do not intend this full-period scope must provide an explicit list. The composite `job_id` is built from the period plus sorted JE IDs so concurrent syncs of different subsets within the same period don't collide. For multi-period sync use `tools.gl.trigger_glbulk_sync`.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "accounting_period_id", "realm_id"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
                  },
                  "accounting_period_id": {
                    "type": "string",
                    "description": "`ap_…`"
                  },
                  "realm_id": {
                    "type": "string"
                  },
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "`je_…` (omit to sync entire period)."
                  },
                  "agent_instance_id": {
                    "type": "string",
                    "description": "Agent attribution — id of the initiating agent instance. Must be sent together with `agent_name`; the sync is then attributed to the agent instead of the API-key user."
                  },
                  "agent_name": {
                    "type": "string",
                    "description": "Agent attribution — display name of the initiating agent. Must be sent together with `agent_instance_id`."
                  }
                }
              },
              "example": {
                "integration_type": "quickbooks",
                "accounting_period_id": "ap_111",
                "realm_id": "9341452123456789",
                "journal_entry_ids": ["je_123", "je_456"]
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted; sync job queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": "6634a1d2c1234567890abcde:6634b001234567890aaaaaa,6634b001234567890bbbbbb",
                    "display_name": "Syncing JE-12, JE-13 to QuickBooks",
                    "accounting_period_id": "ap_111",
                    "integration_type": "quickbooks",
                    "realm_id": "9341452123456789"
                  },
                  "message": "Job queued; subscribe to SSE on job_id for progress"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/bulk-sync": {
      "post": {
        "operationId": "triggerGLBulkSync",
        "summary": "Bulk-sync journal entries across periods",
        "description": "Triggers a bulk sync of journal entries across periods and entities — internally routes by (legal_entity, accounting_period) and queues one provider sync job per group. Handles NetSuite (one auth) / DualEntry (entity→realm map) / QBO+Xero (per-entity config) routing internally. Max 5,000 IDs per request. The legacy filter-based \"select-all\" mode is intentionally NOT exposed in v1.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["journal_entry_ids"],
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 5000
                  },
                  "agent_instance_id": {
                    "type": "string",
                    "description": "Agent attribution — id of the initiating agent instance. Must be sent together with `agent_name`; the sync is then attributed to the agent instead of the API-key user."
                  },
                  "agent_name": {
                    "type": "string",
                    "description": "Agent attribution — display name of the initiating agent. Must be sent together with `agent_instance_id`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted; bulk sync queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "jobs": [
                      {
                        "job_id": "apid:jeid",
                        "display_name": "Syncing March 2026 to QuickBooks"
                      }
                    ],
                    "jobs_count": 1
                  },
                  "message": "Job queued; subscribe to SSE on job_id for progress"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/history": {
      "get": {
        "operationId": "listGLSyncHistory",
        "summary": "List sync history records",
        "description": "Returns historical GL sync runs across all providers. By default only `COMPLETED` rows are returned; pass `include_all=true` to add `JOB_FAILED` / `CANCELED` / `HANGED`, or `include_in_progress=true` to add `STARTED` / `IN_PROGRESS`. For per-entry detail inside one run use `tools.gl.list_glsync_entries`.\n\nThis is ENTENDRE-side SYNC metadata (what was pushed to the GL and when), NOT live provider data — describe it as sync history, not as a QuickBooks/Xero read. For a direct provider read use `tools.gl.raw_glread`; for historical GL search use `tools.gl.search_glrecords`. (Mirrors copilot gl-sync/index.ts:722.)",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "integration_type",
            "in": "query",
            "required": false,
            "description": "Lowercase GL provider: `quickbooks`, `xero`, `netsuite`, or `dualentry`.",
            "schema": {
              "type": "string",
              "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
            }
          },
          {
            "name": "include_in_progress",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            }
          },
          {
            "name": "include_all",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            }
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1SyncHistory"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/entries": {
      "get": {
        "operationId": "listGLSyncEntries",
        "summary": "List entries within a sync run",
        "description": "Returns the entries for one sync_history row. Default (`status=success`) returns the SUCCESSFULLY-SYNCED entries — each carries the journal-entry id it links and the provider (external) transaction id it landed at. Pass `status=failed` to return the FAILED entries for the run (from the sync-attempt log) with their `error_type` / `error_details` — a failed sync writes no success record, so this is the only per-run view of what failed and why (a run with synced_count:5 / failed_count:2 shows only the 5 under `status=success`). `tools.journal_entries.list_journal_entries` with `sync_status=failed` gives the org-wide failure view instead. Use THIS tool to drill into a specific historical sync run; for the run-level list use `tools.gl.list_glsync_history`. Cursors are keyset (anchored to the last row of the previous page) — safe for ETL / export over live-writing data; concurrent inserts cannot duplicate or skip pre-existing rows.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "sync_history_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "description": "`sh_…`"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["success", "failed"]
            },
            "description": "`success` (default) returns synced entries; `failed` returns the run's failed entries with error_type / error_details."
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated success. With `status=failed`, each item instead carries `sync_status`, `error_type`, and `error_details`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "oneOf": [
                          { "$ref": "#/components/schemas/V1SyncEntry" },
                          { "$ref": "#/components/schemas/V1SyncAttempt" }
                        ]
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/recent": {
      "get": {
        "operationId": "listRecentlySyncedJournals",
        "summary": "Recently synced GL journals",
        "description": "Returns journal entries synced to the connected GL provider within the last `hours_ago` hours (1-168, default 1). Covers all supported GL types (QuickBooks, Xero, NetSuite, DualEntry, Campfire) — results are not filterable by provider (there is no `gl_type` parameter); narrow by date window via `hours_ago` or by entity via `legal_entity_ids`.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "hours_ago",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 168
            }
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "description": "Comma-separated `le_…` IDs (filter narrows results).",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["journal_entry_ids", "count", "hours_ago"],
                      "properties": {
                        "journal_entry_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "count": {
                          "type": "integer"
                        },
                        "hours_ago": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/unsync": {
      "post": {
        "operationId": "unsyncJournalEntries",
        "summary": "Synchronously unsync journal entries from the GL provider",
        "description": "Voids the external general ledger record (QuickBooks, Xero, NetSuite, DualEntry) for up to 100 journal entries in one synchronous call, leaving the entries `POSTED` in Entendre. DESTRUCTIVE on the external GL — writes voids to the partner system, may take several seconds per entry. For single-entry unsyncing use `tools.journal_entries.unsync_journal_entry`; for sets larger than 100 use the async `/gl-sync/bulk-unsync` endpoint. To unsync every synced entry in a whole accounting period at once, use `tools.gl.unsync_accounting_period` (the broadest unsync variant). Pass `idempotencyKey` to make the call replay-safe.\n\nAfter unsyncing, do NOT trust the Entendre-side succeeded/failed counts alone — verify against the LIVE external GL with `count_glsynced_records` over the affected accounting-date window (widened ±1 day). The removal is complete only when it reports 0 remaining synced rows (or your intended non-zero remainder).",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["journal_entry_ids"],
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "Prefixed journal-entry IDs (`je_…`); up to 100 per call."
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the number of distinct journal-entry ids in the request, otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "succeeded": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "journal_entry_id": {
                                "type": "string"
                              },
                              "external_type": {
                                "type": "string",
                                "nullable": true
                              },
                              "external_id": {
                                "type": "string",
                                "nullable": true
                              },
                              "not_found_in_external": {
                                "type": "boolean"
                              }
                            }
                          }
                        },
                        "failed": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "journal_entry_id": {
                                "type": "string"
                              },
                              "reason": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "no_auth_record": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "journal_entry_id": {
                                "type": "string"
                              },
                              "legal_entity_id": {
                                "type": "string",
                                "nullable": true
                              }
                            }
                          }
                        },
                        "no_record": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "daily_limit_reached": {
                          "type": "object",
                          "nullable": true,
                          "properties": {
                            "remaining": {
                              "type": "integer"
                            },
                            "day_limit_remaining": {
                              "type": "integer"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/unsync-period": {
      "post": {
        "operationId": "unsyncAccountingPeriod",
        "summary": "Unsync every entry in an accounting period",
        "description": "Voids the external general ledger record (QuickBooks, Xero, NetSuite, DualEntry) for EVERY synced journal entry within a single accounting period. DESTRUCTIVE — this is the broadest unsync variant and can affect thousands of partner-system records at once; periods with high JE volume may take several minutes to drain. Typically used as a prerequisite when reopening a closed month so the period can be re-synced cleanly from a corrected starting point. The Entendre journal entries themselves remain `POSTED` with balances intact — only the external GL records are voided. For unsyncing a single entry use `tools.journal_entries.unsync_journal_entry`; for an explicit list of up to 100 entries use `tools.gl.unsync_journal_entries`. Pass `idempotencyKey` to make the call replay-safe.\n\nGiven the blast radius, send `dry_run: true` first to see how many synced entries in the period would actually be voided (the `voidable_entries` count), then re-send with `confirm_count` set to that number. After unsyncing, do NOT trust the Entendre-side counts alone — verify against the LIVE external GL with `count_glsynced_records` over the period's accounting-date window (widened ±1 day); the removal is complete only when it reports 0 remaining synced rows.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["accounting_period_id"],
                "properties": {
                  "accounting_period_id": {
                    "type": "string",
                    "description": "`ap_…`"
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the `voidable_entries` count that `dry_run` reports (the synced entries in the period that will actually be voided in the external GL, not every JE in the period), otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, returns the `voidable_entries` count (synced entries in the period that will actually be voided) alongside `entries_in_period`, without voiding anything. Use to preview the exact scope before committing."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "accounting_period_id": {
                          "type": "string"
                        },
                        "entries_processed": {
                          "type": "integer"
                        },
                        "succeeded": {
                          "type": "integer"
                        },
                        "failed": {
                          "type": "integer"
                        },
                        "no_auth_record": {
                          "type": "integer"
                        },
                        "no_record": {
                          "type": "integer"
                        },
                        "daily_limit_reached": {
                          "type": "object",
                          "nullable": true,
                          "properties": {
                            "remaining": {
                              "type": "integer"
                            },
                            "day_limit_remaining": {
                              "type": "integer"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/bulk-unsync": {
      "post": {
        "operationId": "triggerBulkUnsync",
        "summary": "Async bulk unsync",
        "description": "Triggers an asynchronous bulk unsync of journal entries from their external GL. Provide either an explicit `journal_entry_ids` list OR filters (`ledger_account_ids`, `statuses`, `is_sync`, `start_date`, `end_date`) to select-all — at least one is required. Pass `is_sync: true` to target only entries already synced to a GL. Returns 202 with an `unsync-<org>-<ts>` job_id, or 200 with `job_id: null` when a filter matches no entries (no-op). The resolved set is capped at 5,000 entries. Use `/gl-sync/cancel-unsync` to abort a running job.\n\nBecause a filter can resolve to thousands of records, send `dry_run: true` first to see the exact matched set, then re-send with `confirm_count` set to the reported match count. After the job completes, do NOT trust the Entendre-side counts alone — verify against the LIVE external GL with `count_glsynced_records` over the affected accounting-date window (widened ±1 day); the removal is complete only when it reports 0 remaining synced rows.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "At least one of `journal_entry_ids` or a filter is required.",
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 5000,
                    "description": "Explicit journal entry IDs (`je_` prefix). Mutually combinable with filters; the resolved set is deduped."
                  },
                  "ledger_account_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 5000,
                    "description": "Filter: entries posting to any of these ledger accounts (`lac_` prefix)."
                  },
                  "statuses": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": ["DRAFT", "IN_PROGRESS", "POSTED", "UNPOSTED", "REVERSED", "ERROR"]
                    },
                    "description": "Filter: entry statuses (case-insensitive)."
                  },
                  "is_sync": {
                    "type": "boolean",
                    "description": "Filter: sync state. Pass `true` to target only entries already synced to a GL."
                  },
                  "start_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Filter: inclusive lower bound on accounting date."
                  },
                  "end_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Filter: inclusive upper bound on accounting date (end-of-day)."
                  },
                  "synced_start_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Inclusive lower bound on `last_synced_at` (durable sync stamp; survives unsync)."
                  },
                  "synced_end_date": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Inclusive upper bound on `last_synced_at`."
                  },
                  "sync_history_ids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "pattern": "^sh_[0-9a-fA-F]{24}$"
                    },
                    "description": "Unsync the members of specific sync batches (ids from GET /gl-sync/history)."
                  },
                  "display_name": {
                    "type": "string"
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Optional scope guard. When provided, MUST equal the resolved match count (as reported by `dry_run`), otherwise the request is rejected with 400. Forces the caller to acknowledge intended blast radius."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "When true, resolves the filter and returns the matched journal-entry ids without enqueuing the unsync job. Use to preview the exact scope before committing."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "No entries matched the filter; nothing queued (no-op).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_id": {
                          "type": "null"
                        },
                        "total_count": {
                          "type": "integer"
                        }
                      }
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": null,
                    "total_count": 0
                  },
                  "message": "No entries matched; nothing to unsync"
                }
              }
            }
          },
          "202": {
            "description": "Accepted; job queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_id": {
                          "type": ["string", "null"],
                          "description": "BullMQ job id, or `null` when no entries matched (no-op)."
                        },
                        "total_count": {
                          "type": "integer"
                        }
                      }
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "data": {
                    "job_id": "unsync-orgabc123-1714400000000",
                    "total_count": 1234
                  },
                  "message": "Job queued; subscribe to SSE on job_id for progress"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/cancel-unsync": {
      "post": {
        "operationId": "cancelBulkUnsync",
        "summary": "Cancel a running bulk-unsync job",
        "description": "Cancels a running bulk-unsync job by setting a Redis flag the worker checks on each iteration. Best-effort: items already processed before the flag is set CANNOT be reverted (DESTRUCTIVE — partial unsync stands). Returns 403 if the `job_id` does not embed the caller's organization. Use `tools.jobs.get_job_status` to confirm cancellation took effect.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["job_id"],
                "properties": {
                  "job_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "cancelled": {
                          "type": "boolean"
                        },
                        "job_id": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "cancelled": true,
                    "job_id": "unsync-orgabc123-1714400000000"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/real-time": {
      "post": {
        "operationId": "toggleRealTimeSync",
        "summary": "Toggle real-time sync",
        "description": "Toggles real-time sync mode for a GL integration — when on, every newly POSTED journal entry is auto-pushed to the provider without an explicit sync call. Incompatible with `should_rolled_up: true` configs; returns 409 if any rollup config exists for the (org, provider). Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["integration_type", "is_real_time"],
                "properties": {
                  "integration_type": {
                    "type": "string",
                    "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
                  },
                  "is_real_time": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "is_real_time": {
                          "type": "boolean"
                        },
                        "configs_updated": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/validate-direction": {
      "post": {
        "operationId": "validateJEDirection",
        "summary": "Validate journal entry debit/credit direction before sync",
        "description": "Determines whether each journal entry's payment line debit/credit direction matches its linked transaction direction (outbound → payment line CREDIT, inbound → payment line DEBIT), flagging entries with reversed debit/credit. A pre-sync safety check, GL-agnostic — run this before syncing to any external GL. Journal entries with no linked transaction (manual JEs) are reported as valid and skipped.",
        "x-mcp-read-only": true,
        "tags": ["GL"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["journal_entry_ids"],
                "properties": {
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "Journal entry id (`je_…`)."
                    },
                    "minItems": 1,
                    "maxItems": 200
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-entry validation results plus a summary.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "results": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "journal_entry_id": {
                                "type": "string"
                              },
                              "sequence_number": {
                                "type": "string",
                                "nullable": true
                              },
                              "valid": {
                                "type": "boolean"
                              },
                              "reason": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "summary": {
                          "type": "object",
                          "properties": {
                            "total": {
                              "type": "integer"
                            },
                            "valid": {
                              "type": "integer"
                            },
                            "failed": {
                              "type": "integer"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/duplicates/scan": {
      "post": {
        "operationId": "scanGLDuplicates",
        "summary": "Scan for duplicate journal entries",
        "description": "Triggers a scan that, for each synced journal entry in the supplied date window, queries QuickBooks Online for matches by (date, amount, vendor, account) plus cross-type groups (Purchase ⇄ Expense, Deposit ⇄ Payment). Returns matches that are NOT the entry's own synced transaction — i.e., suspected duplicates that need operator review. Use `tools.gl.list_recently_synced_journals_for_duplicates` to enumerate recently synced entries before scanning. Scan limits: non-QBO scans are capped at a 30-day date_from/date_to span, and the synced-JE count is capped at 100: when the response carries scan_truncated true, narrow the window or pass explicit journal_entry_ids. A truncated scan is NOT proof of no duplicates. Per-JE failures are isolated in the response errors list and do not abort the scan: surface that count to the user. Remediation is dashboard-only: there is no duplicate-removal MCP tool, so direct the user to the GL reconciliation dashboard to resolve any duplicates found.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["scan_type"],
                "properties": {
                  "scan_type": {
                    "type": "string",
                    "enum": ["weekly_comprehensive", "retroactive", "monthly"]
                  },
                  "scan_window": {
                    "type": "string",
                    "enum": ["24h", "48h", "7d"]
                  },
                  "date_from": {
                    "type": "string",
                    "format": "date"
                  },
                  "date_to": {
                    "type": "string",
                    "format": "date"
                  },
                  "period_name": {
                    "type": "string"
                  },
                  "legal_entity_id": {
                    "type": "string"
                  },
                  "journal_entry_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 200
                  },
                  "gl_type": {
                    "type": "string",
                    "enum": ["QUICKBOOKS", "XERO", "NETSUITE", "DUALENTRY", "CAMPFIRE"],
                    "description": "Optional target GL type. When omitted, auto-resolves against the org's connected GL. Required (per the 400) when the org has more than one GL connected."
                  },
                  "realm_id": {
                    "type": "string",
                    "description": "Optional provider tenant/realm id for multi-realm disambiguation. Requires `gl_type`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "scan_type": {
                          "type": "string"
                        },
                        "scan_window": {
                          "type": "string",
                          "nullable": true
                        },
                        "scanned_count": {
                          "type": "integer"
                        },
                        "duplicates_found": {
                          "type": "integer"
                        },
                        "results": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1DuplicateScanResult"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-sync/duplicates/recent": {
      "get": {
        "operationId": "listRecentlySyncedJournalsForDuplicates",
        "summary": "Recently synced GL journals (alias)",
        "description": "Returns the same shape and behavior as `tools.gl.list_recently_synced_journals` — a namespaced alias for duplicate-detection consumers. Use either tool interchangeably; the response schema is identical. Read-only.",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "hours_ago",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 168
            }
          },
          {
            "name": "legal_entity_ids",
            "in": "query",
            "description": "Comma-separated `le_…` IDs.",
            "schema": {
              "type": "string"
            },
            "style": "form",
            "explode": false
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "journal_entry_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "count": {
                          "type": "integer"
                        },
                        "hours_ago": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/xero/tracking-categories": {
      "get": {
        "operationId": "listXeroTrackingCategories",
        "summary": "List live Xero tracking categories",
        "description": "Returns the live list of Xero tracking categories for the connected tenant — a real-time call to the Xero API (not the cached mirror). Returns 404 if no Xero connection exists for the org; returns 502 on Xero auth / network failure. Used to populate the tag-mapping picker against live Xero data.",
        "tags": ["GL"],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1XeroTrackingCategory"
                      }
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Xero error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/gl-integration/xero/config": {
      "get": {
        "operationId": "getXeroConfig",
        "summary": "Get Xero tag-config for a tenant",
        "description": "Returns the Xero tag-tracking-category configuration for one tenant. Returns `{ realm_id, xero_config: null }` with HTTP 200 (not 404) when no config exists yet — the absent state is a valid initial value. Creating / updating the mapping is not exposed as an MCP tool (human-owned integration control plane).",
        "tags": ["GL"],
        "parameters": [
          {
            "name": "realm_id",
            "in": "query",
            "required": true,
            "description": "Provider-issued tenant identifier (QBO realmId, Xero tenantId, NetSuite accountId).",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "realm_id": {
                          "type": "string"
                        },
                        "xero_config": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/V1XeroConfig"
                            },
                            {
                              "type": "null"
                            }
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/gl-integration/xero/tag-config": {
      "post": {
        "operationId": "upsertXeroTagConfig",
        "summary": "Upsert Xero tracking-category mapping",
        "description": "Upserts the Entendre tag key ↔ Xero tracking category mapping for one tenant. Xero supports a maximum of 2 active tracking categories — pass 1 or 2 mappings with unique `position` (1 or 2) and unique `entendre_tag_key`. Pass `idempotencyKey` to make the call replay-safe.",
        "tags": ["GL"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["realm_id", "tracking_category_mapping"],
                "properties": {
                  "realm_id": {
                    "type": "string"
                  },
                  "tracking_category_mapping": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 2,
                    "items": {
                      "type": "object",
                      "required": ["entendre_tag_key", "xero_tracking_category_name", "position"],
                      "properties": {
                        "entendre_tag_key": {
                          "type": "string",
                          "enum": [
                            "Customer",
                            "Supplier",
                            "System",
                            "ID",
                            "Bank Account",
                            "Cost Center",
                            "Class",
                            "Staff",
                            "Product"
                          ]
                        },
                        "xero_tracking_category_name": {
                          "type": "string"
                        },
                        "position": {
                          "type": "integer",
                          "enum": [1, 2]
                        }
                      }
                    }
                  }
                }
              },
              "example": {
                "realm_id": "xero-tenant-uuid",
                "tracking_category_mapping": [
                  {
                    "entendre_tag_key": "Customer",
                    "xero_tracking_category_name": "Department",
                    "position": 1
                  },
                  {
                    "entendre_tag_key": "Supplier",
                    "xero_tracking_category_name": "Region",
                    "position": 2
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "realm_id": {
                          "type": "string"
                        },
                        "tracking_category_mapping": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/V1XeroTrackingCategoryMapping"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Provider error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/activities": {
      "get": {
        "operationId": "listActivities",
        "summary": "List background-job activity rows",
        "description": "Returns the calling user's background-job activity rows (BullMQ job progress, downloads, long-running computations) for the authenticated organization. Defaults to terminal statuses (`completed`, `failed`) and to `scope=mine` — set `scope=org` to see every user's activities in the org (admin view). Activities are soft-deleted via `POST /v1/activities/dismiss` — dismissed rows are excluded automatically. Pair with `GET /v1/activities/in-progress-count` for the badge counter shown next to the activity panel.",
        "tags": ["Activities"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated lowercase statuses: `pending`, `in_progress`, `completed`, `failed`. Default: `completed,failed` (terminal-only, matches the activity panel default). `in_progress` expands to `pending`+`in_progress` server-side."
          },
          {
            "name": "scope",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["mine", "org"]
            },
            "description": "`mine` (default) returns only activities initiated by the calling user. `org` returns every activity in the org including system/cron jobs — admin / firm-mode view. Defaulting to `mine` matches legacy `/api/activities` behaviour and prevents accidental cross-user visibility of signed download URLs."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, response includes `total_count` (extra `countDocuments` round-trip — opt-in for efficiency)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of activities.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1Activity"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Present only when `include_count=true`."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/activities/in-progress-count": {
      "get": {
        "operationId": "getActivityInProgressCount",
        "summary": "Count in-progress activities",
        "description": "Returns the count of activities in `PENDING` or `IN_PROGRESS` status for the authenticated organization (non-deleted). Used by the activity panel to show the \"X jobs running\" badge. Cheap (single `countDocuments`) — safe to poll. Scope is org-wide (not per-user) so admins see the full running-jobs count.",
        "tags": ["Activities"],
        "responses": {
          "200": {
            "description": "In-progress activity count.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["count"],
                      "properties": {
                        "count": {
                          "type": "integer",
                          "minimum": 0
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/activities/dismiss": {
      "post": {
        "operationId": "dismissActivities",
        "summary": "Dismiss (soft-delete) terminal activities",
        "description": "Soft-deletes activities scoped to terminal statuses (`COMPLETED` or `FAILED`) only — `PENDING` / `IN_PROGRESS` rows are ignored even if their ids are in the body. Pass `{ ids: [\"act_...\"] }` to dismiss a list (max 200) or `{ all: true }` to dismiss every terminal activity for the calling user. The verb sub-path is intentional (per the v1 REST §Q checklist): dismiss is a domain state transition, not a bare DELETE, since the row is preserved for audit and only its `isDeleted` flag flips. Idempotent on the same ids.",
        "tags": ["Activities"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "`act_…`"
                    },
                    "minItems": 1,
                    "maxItems": 200,
                    "description": "Activity ids to dismiss. Mutually exclusive with `all`."
                  },
                  "all": {
                    "type": "boolean",
                    "description": "When `true`, dismiss every terminal activity for the calling user. Mutually exclusive with `ids`."
                  }
                }
              },
              "example": {
                "ids": ["act_507f1f77bcf86cd799439011"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Number of activities actually dismissed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["dismissed"],
                      "properties": {
                        "dismissed": {
                          "type": "integer",
                          "minimum": 0
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/notifications": {
      "get": {
        "operationId": "listNotifications",
        "summary": "List in-app notifications",
        "description": "Returns paginated in-app notifications for the authenticated organization. Filter by `type` (CUSTOM / JOB / TASK / COPILOT), `is_read` flag, or full-text `q` against message+subType. Pair with `GET /v1/notifications/unread-count` for the badge counter and `POST /v1/notifications/mark-read` to transition rows.",
        "tags": ["Notifications"],
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["CUSTOM", "JOB", "TASK", "COPILOT"]
            },
            "description": "Filter by notification type."
          },
          {
            "name": "is_read",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Filter on read state. Omit for both."
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            },
            "description": "Case-insensitive substring against `message` and `subType`."
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "When `true`, response includes `total_count` (opt-in extra round-trip)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of notifications.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1Notification"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "total_count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteNotifications",
        "summary": "Bulk-delete notifications",
        "description": "Deletes notifications for the authenticated organization with hard-delete semantics. Body shape: `{ ids: [\"nfn_...\"] }` (max 200) deletes the listed rows; `{ all: true }` wipes every notification for the org. Cross-org defense: ids are always filtered by the calling org's `organizationId`, so a caller cannot delete another tenant's rows even by guessing foreign ObjectIds. No separate `DELETE /v1/notifications/{id}` — single-row deletes use `{ ids: [...] }`.",
        "tags": ["Notifications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "`nfn_…`"
                    },
                    "minItems": 1,
                    "maxItems": 200
                  },
                  "all": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deletion count.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted"],
                      "properties": {
                        "deleted": {
                          "type": "integer",
                          "minimum": 0
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/notifications/unread-count": {
      "get": {
        "operationId": "getNotificationUnreadCount",
        "summary": "Unread notification counts grouped by type",
        "description": "Returns the count of unread notifications for the authenticated organization, broken down by `NOTIFICATION_TYPE` (CUSTOM, JOB, TASK, COPILOT). All four keys are always present in `by_type` (zeros included) so the response shape stays stable for badge UIs. Soft-deleted rows are excluded.",
        "tags": ["Notifications"],
        "responses": {
          "200": {
            "description": "Unread counts by type.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["total", "by_type"],
                      "properties": {
                        "total": {
                          "type": "integer",
                          "minimum": 0
                        },
                        "by_type": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "integer",
                            "minimum": 0
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/notifications/mark-read": {
      "post": {
        "operationId": "markNotificationsRead",
        "summary": "Bulk mark notifications as read or unread",
        "description": "Marks notifications' `is_read` flag for the authenticated organization. Body shape: `{ ids: [\"nfn_...\"], is_read?: boolean }` to mark a list (max 200), or `{ all: true, is_read?: boolean }` to mark every notification for the org. `is_read` defaults to `true`; pass `false` to flip rows back to unread. Cross-org defense: ids filtered by org; cannot mutate another tenant's rows.",
        "tags": ["Notifications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "`nfn_…`"
                    },
                    "minItems": 1,
                    "maxItems": 200
                  },
                  "all": {
                    "type": "boolean"
                  },
                  "is_read": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Update counts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["matched", "modified", "is_read"],
                      "properties": {
                        "matched": {
                          "type": "integer",
                          "minimum": 0
                        },
                        "modified": {
                          "type": "integer",
                          "minimum": 0
                        },
                        "is_read": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/stripe/payouts": {
      "get": {
        "operationId": "listStripePayouts",
        "summary": "List Stripe payouts",
        "description": "Lists persisted Stripe payouts for the organization. Each payout includes the provider amount, fee details, and charge-to-invoice allocations. Use this to audit Stripe cash transfers and reconcile them to invoices.",
        "tags": ["Stripe"],
        "parameters": [
          { "$ref": "#/components/parameters/LimitQuery" },
          { "$ref": "#/components/parameters/CursorQuery" },
          { "name": "page", "in": "query", "schema": { "type": "integer", "minimum": 1 } }
        ],
        "responses": {
          "200": {
            "description": "Persisted Stripe payouts.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StripePayoutListResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/stripe/payouts/{id}": {
      "get": {
        "operationId": "getStripePayout",
        "summary": "Get a Stripe payout",
        "description": "Gets one persisted Stripe payout by its `spo_` id. The response includes fee details and charge-to-invoice allocations.",
        "tags": ["Stripe"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": {
            "description": "The Stripe payout.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/StripePayoutSingleResponse" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/stripe/invoices": {
      "get": {
        "operationId": "listStripeInvoices",
        "summary": "List Stripe AR invoices",
        "description": "Lists the organization's persisted Stripe AR invoices. Pass `payout_id` to limit results to invoices allocated to one persisted Stripe payout.",
        "tags": ["Stripe"],
        "parameters": [
          { "$ref": "#/components/parameters/LimitQuery" },
          { "$ref": "#/components/parameters/CursorQuery" },
          { "name": "page", "in": "query", "schema": { "type": "integer", "minimum": 1 } },
          {
            "name": "payout_id",
            "in": "query",
            "description": "Stripe payout id such as `po_...`.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Persisted Stripe AR invoices.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/StripeInvoiceListResponse" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cash-application/invoices": {
      "get": {
        "operationId": "searchCashApplicationInvoices",
        "summary": "Search Stripe AR invoices (accounts-receivable, not QBO)",
        "description": "Searches the organization's Stripe AR (accounts-receivable) invoices — the source of truth for customer-invoice and AR questions when Stripe AR is connected. Use this for \"do we have open invoices for <customer>\", \"find the invoice for this deposit\", AR aging, and cash-application matching. This is NOT the QuickBooks invoice list: this endpoint reads Entendre's own Stripe AR store, so when Stripe AR is connected answer AR/Stripe invoice questions here rather than from the GL — an empty or partial page here is never a reason to re-answer the same Stripe question out of QuickBooks. The GL side is a different question with a different reader: for a QBO org's GL-side customer invoices — the org has no Stripe AR connection, or a customer resolved here and still returned no rows on a QuickBooks-connected org — use `tools.gl.search_glrecords` with `record_types: ['Invoice']`, and say which store you answered from. `tools.gl.raw_glread` with a provider `/query` stays the escape hatch for provider-native fields (e.g. selecting `Balance`) that neither list exposes. `customer_name` is resolved by a case-insensitive SUBSTRING (fuzzy) match; if it matches more than one customer the response is 200 with empty `data`, a `message`, and a `candidates` list of the matching customers — pick one and re-issue with its `stripe_customer_id` (it is NOT a 400). It may be combined with `stripe_customer_id`, in which case the id takes precedence and the name is ignored. `status` is a comma-separated list defaulting to `open`; the documented values are `open | paid | draft | void | uncollectible`, but an unknown value is NOT rejected — it simply matches nothing. `amount` with `tolerance` (default 5) performs a Decimal128 range query, including when `amount=0`. Results use stable keyset pagination by invoice date plus id; follow `next_cursor` while `has_more` is true. `has_more: true` means more matches exist beyond this page: a caller that reads only the first page (e.g. one that cannot send a cursor) holds an INCOMPLETE set — this matters most on `amount` searches whose tolerance band can exceed one page — so do not treat a single page as the full candidate set when `has_more` is true. A `message` is present ONLY when customer resolution missed — `customer_name` matched no customer, or matched several (returned with `candidates`); a customer that DID resolve but has no matching invoices returns `data: []` with NO `message`. Either way an empty `data: []` means nothing matched THIS filter, never that the org has no receivables.\n\n### Fallback when no OPEN invoice matches a resolved customer\n\nIf a customer resolved but has no `open` invoice matching the deposit, retry with `status=paid`: the AR team may have already marked the invoice paid in Stripe while the Entendre journal entry is not yet posted. When you apply against a paid invoice, DISCLOSE it — 'applied internally; the Stripe invoice was already marked paid, so the deposit is cleared but the JE is not synced to the GL' — do not present it as a fresh open-invoice match. (Mirrors copilot cash-application/index.ts:258,346.)",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "name": "customer_name",
            "in": "query",
            "description": "Customer display name; resolved by a case-insensitive SUBSTRING (fuzzy) match. May be combined with `stripe_customer_id`, in which case the id takes precedence and this is ignored. No match returns 200 with empty `data`; a name matching more than one customer also returns 200 with empty `data` plus a `candidates` list (each with `stripe_customer_id` + `name`) — pass one candidate's `stripe_customer_id` to disambiguate.",
            "schema": { "type": "string", "minLength": 1, "maxLength": 256 }
          },
          {
            "name": "stripe_customer_id",
            "in": "query",
            "description": "Stripe customer id. May be supplied together with `customer_name`; when both are present the id takes precedence and the name is ignored.",
            "schema": { "type": "string", "minLength": 1, "maxLength": 256 }
          },
          {
            "name": "status",
            "in": "query",
            "description": "Comma-separated Stripe AR invoice statuses. Default `open`. Documented values: `open | paid | draft | void | uncollectible`. An unknown value is not rejected — it passes through and simply matches nothing.",
            "schema": { "type": "string", "minLength": 1, "maxLength": 64 }
          },
          {
            "name": "amount",
            "in": "query",
            "description": "Deposit amount to match against invoice `amount_due`, as a Decimal128-safe non-negative decimal string (e.g. `593556.86`). Zero is a valid, active filter.",
            "schema": { "type": "string" }
          },
          {
            "name": "tolerance",
            "in": "query",
            "description": "Absolute Decimal128 matching band around `amount` (default 5). Supplying it without `amount` returns 400.",
            "schema": { "type": "string" }
          },
          {
            "name": "date_from",
            "in": "query",
            "description": "Inclusive lower bound on invoice date. Accepts either a bare `YYYY-MM-DD` date or a full ISO 8601 timestamp (the endpoint and copilot both send date-only values, so no `date-time` format is imposed).",
            "schema": { "type": "string" }
          },
          {
            "name": "date_to",
            "in": "query",
            "description": "Inclusive upper bound on invoice date. A bare `YYYY-MM-DD` date is clamped to end-of-day UTC so the whole day is included; a full ISO 8601 timestamp is used as the exact instant (NOT clamped) — `date_to=2026-04-30T00:00:00Z` excludes everything dated during April 30, so send the bare date when you mean 'through that day'.",
            "schema": { "type": "string" }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size (1-100). This endpoint defaults to 100 when omitted (the legacy no-amount cap), so a single page covers the same breadth the legacy search returned — unlike most v1 lists, which default to 50. Pass an explicit value for a smaller page.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 }
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "One stable keyset page of matching Stripe AR invoices. Follow `has_more` / `next_cursor` to page through all matches; `truncated` is always `false` here (see its property description).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "count",
                    "has_more",
                    "next_cursor",
                    "truncated",
                    "customer_name",
                    "stripe_customer_id"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": ["string", "null"] },
                          "organization_id": { "type": ["string", "null"] },
                          "invoice_number": { "type": ["string", "null"] },
                          "stripe_invoice_id": { "type": ["string", "null"] },
                          "status": { "type": ["string", "null"] },
                          "amount_due": { "type": "string" },
                          "total_amount": { "type": "string" },
                          "invoice_date": { "type": ["string", "null"] },
                          "due_date": { "type": ["string", "null"] },
                          "customer_name": { "type": ["string", "null"] },
                          "stripe_customer_id": { "type": ["string", "null"] }
                        }
                      }
                    },
                    "count": { "type": "integer" },
                    "has_more": { "type": "boolean" },
                    "next_cursor": { "type": ["string", "null"] },
                    "truncated": {
                      "type": "boolean",
                      "description": "Always `false` for this endpoint. Per the v1 convention, `truncated` signals only that a hard result cap was hit and remaining matches are unreachable without narrower filters; this endpoint is keyset-paginated with no such cap, so nothing is ever truncated. Use `has_more` / `next_cursor` to page through all matches."
                    },
                    "customer_name": { "type": ["string", "null"] },
                    "stripe_customer_id": { "type": ["string", "null"] },
                    "message": {
                      "type": "string",
                      "description": "Present ONLY on a customer-resolution miss: `customer_name` matched no customer, or matched more than one (returned alongside `candidates`). A customer that DID resolve but has no matching invoices returns `data: []` with NO `message`. An explanatory note, not an error."
                    },
                    "candidates": {
                      "type": "array",
                      "description": "Present ONLY when `customer_name` matched more than one customer: the candidate customers to disambiguate between. Re-issue the search with one candidate's `stripe_customer_id`. Capped at 10 candidates: when more than 10 customers match, the overflow is signalled ONLY in the free-text message ('matched more than 10 customers'). Do not present a capped list as the complete set of possibilities; ask for a more specific name.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "stripe_customer_id": { "type": ["string", "null"] },
                          "name": { "type": ["string", "null"] }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/cash-application/config": {
      "get": {
        "operationId": "getCashApplicationConfig",
        "summary": "Read cash-application (Stripe AR) configuration",
        "description": "Retrieves the org's Stripe AR connection configuration — the cash-application settings (whether it is `enabled`, the auto-apply toggle, Plaid linkage, wire-fee handling). Use this to check whether cash application / Stripe AR is set up before searching invoices (`tools.cash_applications.search_cash_application_invoices`) or running a sweep — but a 200 only means a connection row EXISTS; read the `enabled` field to confirm it is actually turned on (a disabled config still returns 200, not 404). The lookup is by organization only and does not distinguish legal entities: if an org ever has more than one Stripe AR connection this returns one connection's settings, described as the org's, not a per-entity view. Returns 404 when no Stripe AR connection exists for the org — treat that as \"cash application is not configured\" rather than surfacing a raw error. Read-only; never mutates.",
        "tags": ["Cash Applications"],
        "responses": {
          "200": {
            "description": "The org's cash-application configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "description": "Fixed cash-application config projection from the Stripe AR connection — a stable set of fields, not arbitrary passthrough. Scoped to the org's single connection; multi-entity orgs are not currently distinguished.",
                      "properties": {
                        "id": {
                          "type": ["string", "null"],
                          "description": "`cac_`-prefixed config id (the underlying Stripe AR connection)."
                        },
                        "organization_id": { "type": ["string", "null"] },
                        "legal_entity_id": {
                          "type": ["string", "null"],
                          "description": "Entity the connection is registered under; null when unassigned."
                        },
                        "auto_post_journals": { "type": "boolean" },
                        "enabled": {
                          "type": "boolean",
                          "description": "The cash-application auto-apply toggle. False (or an absent config) means cash application is not active for the org."
                        },
                        "plaid_account_id": {
                          "type": ["string", "null"],
                          "description": "Raw PlaidAccount id the sweep reads deposits from."
                        },
                        "require_stripe_approval": {
                          "type": "boolean",
                          "description": "True when applied matches wait in applied_pending_stripe until a human approves marking Stripe invoices paid."
                        },
                        "wire_fee_account_id": {
                          "type": ["string", "null"],
                          "description": "Ledger account absorbing wire-fee residuals; null when unconfigured."
                        },
                        "stripe_payout_plaid_account_id": { "type": ["string", "null"] },
                        "created_at": { "type": ["string", "null"] },
                        "updated_at": { "type": ["string", "null"] }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "operationId": "updateCashApplicationConfig",
        "summary": "Update cash-application (Stripe AR) configuration",
        "description": "Updates the org's Stripe AR connection settings for cash application — the enabled toggle, Plaid account linkage, Stripe-approval requirement, and wire-fee account. Merges `settings` over the existing configuration; omitted fields keep their current value. **Turning `enabled: true` on also sets `auto_post_journals: false`** — enabling cash application makes it the source of truth for matched deposits instead of blanket auto-posting, so review that side effect with the user when flipping the toggle on.\n\nNot a create endpoint — `connection_id` must reference an existing Stripe AR connection in your organization (`GET /v1/cash-application/config` for the current one); there is no path to create a new connection here. Read-only key holders cannot call this (`write:cash-application` required).",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["connection_id"],
                "additionalProperties": false,
                "properties": {
                  "connection_id": {
                    "type": "string",
                    "description": "Prefixed `cac_` id of the existing Stripe AR connection to update."
                  },
                  "settings": {
                    "type": "object",
                    "additionalProperties": false,
                    "description": "Partial update — omitted fields keep their current value.",
                    "properties": {
                      "enabled": {
                        "type": "boolean",
                        "description": "Turning this on also sets auto_post_journals to false (cash application becomes the source of truth)."
                      },
                      "plaid_account_id": {
                        "type": "string",
                        "description": "Raw PlaidAccount id the sweep reads deposits from (not a prefixed id)."
                      },
                      "require_stripe_approval": {
                        "type": "boolean",
                        "description": "When true, applied matches wait in `applied_pending_stripe` until a human approves marking the Stripe invoice paid."
                      },
                      "wire_fee_account_id": {
                        "type": "string",
                        "description": "Prefixed `lac_` ledger account absorbing wire-fee residuals; must be a postable leaf account in your organization."
                      }
                    }
                  }
                }
              },
              "example": {
                "connection_id": "cac_64f1a2b3c4d5e6f708192a3b",
                "settings": {
                  "enabled": true
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated configuration (same shape as `GET /v1/cash-application/config`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "description": "Same projection as GET /v1/cash-application/config."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications": {
      "get": {
        "operationId": "listCashApplications",
        "summary": "List cash-applications",
        "description": "List cash-applications for the authenticated organization. Resource-shaped successor to `GET /v1/cash-application/matches`.\n\n### Status filters\n\n- `status` — caller-facing 4-state vocabulary:\n  - `draft`\n  - `applied`\n  - `partially_applied`\n  - `voided`\n- `stripe_status`:\n  - `pending`\n  - `synced`\n  - `failed`\n\nThe new statuses map onto the legacy 7-state DB enum internally — callers always speak the new vocabulary.\n\n### Pagination\n\n- Opaque `cursor` (preferred) — required when filtering by `applied` or `partially_applied`, which are post-fetch derived states.\n- Legacy `page` + `page_size` for simple filters.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["draft", "applied", "partially_applied", "voided"]
            },
            "description": "Filter on the resource status (caller vocabulary). `partially_applied` and `applied` force cursor-based paging."
          },
          {
            "name": "stripe_status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["pending", "synced", "failed"]
            },
            "description": "Filter on the Stripe sync sub-state. More specific than `status` — takes precedence when both supplied."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-indexed page number (legacy form). Ignored when `cursor` is supplied or when `status` is a computed state."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "description": "Page size (1-100). Note: this endpoint uses `page_size`, not `limit`."
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "$ref": "#/components/parameters/LimitQuery"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of cash-applications.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor", "status_counts"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1CashApplication"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "next_page": {
                      "type": ["integer", "null"],
                      "description": "Legacy page-based pagination hint. `null` when paging via `cursor` or when a computed-status filter is in play."
                    },
                    "status_counts": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      },
                      "description": "Org-wide count of cash-applications by status, independent of the `status`/`stripe_status`/`cursor` filters (the dashboard renders every bucket from one request). Keyed by the LEGACY 7-state DB status (`pending`, `proposed`, `applied_pending_stripe`, `completed`, `rejected`, `expired`, `exception`) — NOT the 4-state `data[].status` vocabulary, since `partially_applied` is a derived state with no DB enum. Soft-deleted rows are excluded."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createAndApplyCashApplication",
        "summary": "Create and apply a cash-application in one call",
        "description": "Create and apply a cash-application atomically — posts the journal entry and claims the deposit in one call. Resource-shaped successor to `POST /v1/cash-application/apply`.\n\nUse `POST /v1/cash-applications/preview` first if you want suggested allocations without committing. Use `POST /v1/cash-applications/{id}/unapply` to reverse.\n\n### Allocation rules\n\n- Each allocation's `amount` MUST equal the corresponding invoice's `amount_due`. Partial allocations are not yet supported in v1 and return 400 if amounts don't match.\n- All invoices in a single call must belong to the same customer.\n\n### Deposit eligibility\n\n`deposit_transaction_id` must reference a transaction that satisfies every condition below:\n\n- `direction='Credit'`.\n- Sourced from a payment account — bank, Stripe, or an exchange / wallet / Fireblocks account for crypto, or a manual bank statement. Corporate-card and payroll sources (`RAINCARD`, `NIURAL`, `RAIN_BILL_PAY`, `FINCH_PAYROLL`) are NOT eligible deposits.\n- Transaction type is one of `DEPOSIT`, `INCOME`, `REFUND`, `CHARGEBACK`.\n- Not already posted to GL (`has_accounting=false`).\n- Not already claimed by another non-rejected / expired / exception cash-application.\n\n### Validation errors\n\nReturns `400 VALIDATION_ERROR` with a stable `reason` enum in `extra.fields.deposit_transaction_id`. Possible values:\n\n- `not-found`\n- `wrong-direction`\n- `wrong-source-type`\n- `wrong-classification` (legacy error-code name for an ineligible transaction type)\n- `wrong-source-account`\n- `already-posted`\n- `already-claimed`",
        "tags": ["Cash Applications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["deposit_transaction_id", "allocations", "compiled_rules"],
                "properties": {
                  "deposit_transaction_id": {
                    "type": "string",
                    "description": "`txn_…` — the deposit being applied to invoices."
                  },
                  "allocations": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": ["target_type", "target_id", "amount"],
                      "properties": {
                        "target_type": {
                          "type": "string",
                          "enum": ["invoice"]
                        },
                        "target_id": {
                          "type": "string",
                          "description": "`inv_…`"
                        },
                        "amount": {
                          "type": "string",
                          "description": "Non-negative decimal string. Must equal `invoice.amount_due` (partial allocations deferred)."
                        }
                      }
                    }
                  },
                  "adjustment_ledger_account_id": {
                    "type": "string",
                    "description": "Optional `lac_…` for the adjustment leg. Defaults to the org's configured cash-application adjustment account."
                  },
                  "compiled_rules": {
                    "type": "array",
                    "description": "Required compiled cash-app routing rules from the caller's org-memory. This endpoint always posts (no dry_run preview), so an omitted value would build empty overrides and silently drop route_to_account / require_human_review controls. Pass `[]` to opt into default routing explicitly.",
                    "items": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Cash-application created and applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1CashApplication"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications/preview": {
      "post": {
        "operationId": "previewCashApplication",
        "summary": "Preview cash-application allocations (stateless)",
        "description": "Returns candidate invoice allocations for a deposit transaction with confidence scores — WITHOUT writing anything. Callers (UIs, agents, batch processors) can call this repeatedly to evaluate options before committing via `POST /v1/cash-applications`.\n\nOptional `stripe_customer_id` narrows the candidate invoice pool for higher-signal suggestions.\n\n### Match tiers\n\n- `exact` — single-invoice perfect match. Confidence 1.0.\n- `multi_invoice` — subset-sum across ≥ 2 invoices. Confidence 0.9.\n- `tolerance` — single-invoice within $50. Confidence 0.7.\n\n### Deposit eligibility\n\n`deposit_transaction_id` must reference a transaction that satisfies every condition below:\n\n- `direction='Credit'`.\n- Sourced from a payment account — bank, Stripe, or an exchange / wallet / Fireblocks account for crypto, or a manual bank statement. Corporate-card and payroll sources (`RAINCARD`, `NIURAL`, `RAIN_BILL_PAY`, `FINCH_PAYROLL`) are NOT eligible deposits.\n- Transaction type is one of `DEPOSIT`, `INCOME`, `REFUND`, `CHARGEBACK`.\n- Not already posted to GL (`has_accounting=false`).\n- Not already claimed by another non-rejected / expired / exception cash-application.\n\n### Validation errors\n\nReturns `400 VALIDATION_ERROR` with a stable `reason` enum in `extra.fields.deposit_transaction_id`. Possible values:\n\n- `not-found`\n- `wrong-direction`\n- `wrong-source-type`\n- `wrong-classification` (legacy error-code name for an ineligible transaction type)\n- `wrong-source-account`\n- `already-posted`\n- `already-claimed`",
        "x-mcp-read-only": true,
        "tags": ["Cash Applications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["deposit_transaction_id"],
                "properties": {
                  "deposit_transaction_id": {
                    "type": "string",
                    "description": "`txn_…`"
                  },
                  "stripe_customer_id": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256,
                    "description": "Optional customer hint — narrows the candidate invoice pool to this customer's open invoices."
                  },
                  "max_suggestions": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 20
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Suggested allocations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "deposit": {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "string"
                            },
                            "amount": {
                              "type": "string"
                            },
                            "memo": {
                              "type": ["string", "null"]
                            },
                            "txn_date": {
                              "type": ["string", "null"],
                              "format": "date-time"
                            }
                          }
                        },
                        "derived_customer": {
                          "type": ["object", "null"]
                        },
                        "suggestions": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "match_type": {
                                "type": "string",
                                "enum": ["exact", "multi_invoice", "tolerance"]
                              },
                              "confidence": {
                                "type": "number"
                              },
                              "total_amount": {
                                "type": "string"
                              },
                              "delta": {
                                "type": "string"
                              },
                              "allocations": {
                                "type": "array",
                                "items": {
                                  "type": "object"
                                }
                              }
                            }
                          }
                        },
                        "candidate_pool_size": {
                          "type": "integer"
                        },
                        "truncated": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications/auto-match": {
      "post": {
        "operationId": "autoMatchCashApplications",
        "summary": "Run the auto-match sweep across uncleared deposits",
        "description": "Auto-match deposits to invoices across the authenticated organization, creating proposed and/or applied cash-applications.\n\n### Response shape\n\nReturns a job-shaped envelope: `{ job_id, status, type, result }`. Currently synchronous (`status` is always `completed`), but the shape is forward-compatible — when this lands async (planned M9) the same `job_id` will let callers poll `GET /v1/jobs/{job_id}` for completion. Always inspect `result.applied`, `result.proposed`, and `result.exceptions` to know what landed.\n\n### Eligibility filter\n\nThe sweep only considers deposits matching the same eligibility rules used by `POST /v1/cash-applications`:\n\n- `direction='Credit'`\n- Payment-source type (bank, Stripe, crypto exchange / wallet / Fireblocks, or manual statement)\n- Deposit-shape classification\n- Not already posted to GL\n- Not already claimed\n\nNon-eligible deposits are silently skipped, so the result counters reflect candidate processing only.\n\n### Multi-source mode\n\nWhen the org has the `enableCashAppMultiSourceMatch` feature flag enabled, the sweep also considers Stripe payouts and crypto receipts (exchange / wallet / Fireblocks) alongside Plaid bank deposits. Default is OFF — existing customers stay Plaid-only until the flag is flipped per-org.",
        "tags": ["Cash Applications"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dry_run": {
                    "type": "boolean",
                    "description": "When `true`, runs the matcher and returns the candidate results without writing any match rows or posting journal entries. Default `false`."
                  },
                  "date_from": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Inclusive lower bound on deposit transaction date."
                  },
                  "date_to": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Inclusive upper bound on deposit transaction date."
                  },
                  "compiled_rules": {
                    "type": "array",
                    "description": "Compiled cash-app routing rules from the caller's org-memory. Optional ONLY for a `dry_run` preview (which posts nothing); for an actual sweep (`dry_run` omitted/false) it is REQUIRED — omitting it would build empty overrides and silently drop route_to_account / require_human_review controls. Pass `[]` to opt into default routing explicitly.",
                    "items": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sweep completed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["job_id", "status", "type"],
                      "properties": {
                        "job_id": {
                          "type": "string",
                          "description": "`caj_…`"
                        },
                        "status": {
                          "type": "string",
                          "enum": ["queued", "completed"]
                        },
                        "type": {
                          "type": "string",
                          "enum": ["cash-application-auto-match"]
                        },
                        "result": {
                          "type": "object"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications/{id}": {
      "get": {
        "operationId": "getCashApplication",
        "summary": "Read a single cash-application",
        "description": "Returns one cash-application by id for the authenticated organization. The `:id` accepts both the new `ca_…` prefix and (via strangler-fig shim) the legacy `cam_…` prefix — both resolve to the same underlying row.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "responses": {
          "200": {
            "description": "Cash-application.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1CashApplication"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateCashApplication",
        "summary": "Edit allocations on a draft cash-application",
        "description": "Update an existing cash-application's allocations (only allowed while `status=draft`). Use after `POST /v1/cash-applications/{id}/unapply` returns an applied row to draft so allocations can be revised before re-applying. Requires `If-Match` header set to the row's current ETag (RFC 7232) — mismatch returns 412 with `expected_etag` / `actual_etag` in the response so the caller can re-sync without a follow-up GET. Allocations follow the same validation as `POST /v1/cash-applications` (same-customer, amount==amount_due, max-200 invoices). Emits `cash_application.updated` webhook with Stripe-style `previous_attributes`.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "name": "If-Match",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "ETag from the previous GET or write. Required for optimistic concurrency."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["allocations"],
                "properties": {
                  "allocations": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": ["target_type", "target_id", "amount"],
                      "properties": {
                        "target_type": {
                          "type": "string",
                          "enum": ["invoice"]
                        },
                        "target_id": {
                          "type": "string"
                        },
                        "amount": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated cash-application. Response `ETag` header set to the new value.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1CashApplication"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "412": {
            "description": "If-Match precondition failed. Response body includes `expected_etag` and `actual_etag`."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications/{id}/apply": {
      "post": {
        "operationId": "applyCashApplication",
        "summary": "Apply a draft cash-application",
        "description": "Apply a draft cash-application. Two modes, selected by the request body:\n\n- **Auto-apply** (`compiled_rules`): posts the journal entry from the match's allocations, claims the deposit, and (depending on org settings) marks the linked invoice paid in Stripe.\n- **Manual JE-link** (`journal_entry_id`): attaches a caller-posted journal entry to the match after validating it (POSTED, balanced, touches the configured bank account, not already linked, and carries the customer tag).\n\nExactly one mode applies: supply `journal_entry_id` for manual mode, otherwise `compiled_rules` is required. The row's `status` becomes `applied` (with `stripe_status: pending`) or fully `applied` (with `stripe_status: synced`) when the org's `requireStripeApproval` setting is false. Both modes return the same envelope. Use `POST /v1/cash-applications/{id}/unapply` to reverse before Stripe-sync, or the refund flow afterwards.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "journal_entry_id": {
                    "type": "string",
                    "description": "`je_…` — a pre-posted journal entry to link (manual mode). When supplied, `compiled_rules` is not required."
                  },
                  "compiled_rules": {
                    "type": "array",
                    "description": "Compiled cash-app routing rules from the caller's org-memory (auto-apply mode). Required unless `journal_entry_id` is supplied. May be empty for no overrides.",
                    "items": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cash-application applied. The same envelope is returned for both auto-apply and manual JE-link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "linked_manually"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1CashApplication"
                    },
                    "journal_sequence_number": {
                      "type": ["string", "null"],
                      "description": "Human-readable sequence of the posted/linked journal entry (e.g. `JE-4821`), or `null` if unavailable."
                    },
                    "linked_manually": {
                      "type": "boolean",
                      "description": "`true` when a caller-supplied `journal_entry_id` was linked (manual mode); `false` for auto-apply."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications/{id}/unapply": {
      "post": {
        "operationId": "unapplyCashApplication",
        "summary": "Reverse an applied cash-application back to draft",
        "description": "Reverses an applied (status=`applied`, `stripe_status=pending`) cash-application: unposts the journal entry, clears the deposit's accounting flags, and transitions the row back to `draft` with allocations preserved for edit-and-resubmit. Cannot unapply a `synced` row (`stripe_status=synced`) — use the refund flow. Emits `cash_application.unapplied` webhook with the optional `reason`.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Optional explanation — surfaced in the webhook event only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cash-application returned to draft.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1CashApplication"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-applications/{id}/void": {
      "post": {
        "operationId": "voidCashApplication",
        "summary": "Void a draft or applied cash-application",
        "description": "Void a cash-application — transitions the row to `voided` status. Voidable from `draft` (no-op accounting) or `applied`+`stripe_status=pending` (unposts the JE). Cannot void a `synced` (Stripe-sealed) row — use the refund flow. Allocations are preserved for audit; the row is no longer actionable. Emits `cash_application.voided` webhook with the optional `reason`.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Optional rejection reason — stored on the row + surfaced in the webhook event."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cash-application voided.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1CashApplication"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/documents": {
      "delete": {
        "operationId": "deleteDocuments",
        "summary": "Delete documents",
        "tags": ["Vault"],
        "description": "Delete one or more vault documents by prefixed `doc_` id. Ids not owned by the caller's org are skipped; the response reports how many were removed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["document_ids"],
                "properties": { "document_ids": { "type": "array", "minItems": 1, "items": { "type": "string" } } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted_document_ids", "message"],
                      "properties": {
                        "deleted_document_ids": { "type": "array", "items": { "type": "string" } },
                        "message": { "type": "string" }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "get": {
        "operationId": "listVaultDocuments",
        "summary": "List Vault documents",
        "description": "Lists documents stored in the Vault (bills, receipts, bank statements, and other uploads), newest first.\n\nCursor pagination: pass `cursor` (or the legacy `page`) and follow the response's `next_cursor`; `limit` (1-100) is the standard alias of the legacy `page_size` (max 200). The effective page is capped at 100 rows, so a larger `page_size` is accepted but returns at most 100; `next_cursor` always round-trips, and `next_page` is also emitted for legacy callers.\n\nBy default each item is a flat document. Pass `?expand=bill,receipt,vendor,statement` to nest the joined records under each item — `expand=statement` is how a statement document's `bnk_` id is resolved before importing its lines.\n\nIMPORTANT: a tool error or an unavailable/failed response is NOT the same as an empty result. Never tell the user there are no documents based on a failed call. If the call errors, retry once with fewer or simpler filters; if it still fails, tell the user the query failed rather than reporting zero documents.",
        "tags": ["Vault"],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Items per page (1-100, default 25). Standard alias of `page_size` (max 200)."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Opaque pagination cursor from a previous response's `next_cursor`. Advances to the next page; takes precedence over `page` when both are sent."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-based page number. Use the response's `next_page` to advance."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            },
            "description": "Items per page (1-200, default 25)."
          },
          {
            "name": "expand",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated join keys to populate on each item: `bill`, `receipt`, `vendor`, `statement`. Omit for the flat document shape."
          },
          {
            "name": "document_types",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated document types to filter by: `bill`, `receipt`, `statement`, `other`."
          },
          {
            "name": "document_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated `doc_<uuid>` ids to filter by."
          },
          {
            "name": "created_from",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Earliest created date (YYYY-MM-DD)."
          },
          {
            "name": "created_to",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Latest created date (YYYY-MM-DD)."
          },
          {
            "name": "search_term",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Full-text match across extracted text, summary, filename, vendor name, and bill/receipt numbers."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated documents (each item is a document envelope).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/VaultDocumentEnvelope"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Opaque cursor for the next page (follow it to paginate); null on the last page or when a `page_size` > 100 was requested (use `next_page` then)."
                    },
                    "next_page": {
                      "type": ["integer", "null"]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/documents/{id}": {
      "patch": {
        "operationId": "updateDocument",
        "summary": "Update a document",
        "tags": ["Vault"],
        "description": "Update a single vault document's filename or folder. Set `filename` to rename and/or `file_directory` to move it to another folder.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "Prefixed `doc_` id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "filename": { "type": "string", "minLength": 1, "maxLength": 512 },
                  "file_directory": { "type": "string", "maxLength": 1024 }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": { "data": { "type": "object", "additionalProperties": true } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "get": {
        "operationId": "getVaultDocument",
        "summary": "Get a Vault document",
        "description": "Returns a single Vault document by its `doc_<uuid>` id, with a time-bounded `signed_url`. A not-found or provider error is not evidence that the Vault has no document. Pass `?expand=` to nest the joined bill / receipt / vendor / statement records.",
        "tags": ["Vault"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          },
          {
            "name": "expand",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated join keys to populate on each item: `bill`, `receipt`, `vendor`, `statement`. Omit for the flat document shape."
          }
        ],
        "responses": {
          "200": {
            "description": "The document envelope (with `signed_url` on `document`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/VaultDocumentEnvelope"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/bills": {
      "get": {
        "operationId": "listVaultBills",
        "summary": "List uploaded/internal Vault bill documents (not live QBO bills)",
        "description": "Lists uploaded/internal Vault bill DOCUMENTS. For a QBO-ONLY org's bill history, prefer `tools.vault.list_qbobills`: QuickBooks Bills JSON is the live source of truth there and Vault rows can be stale or empty (this tool returning zero is NOT proof the org has no bills). On a HYBRID org that uses both QuickBooks and Vault, do not treat QuickBooks as superseding Vault — Vault may hold bills QuickBooks does not, so consult both when the user wants a complete picture. Use this tool for PDF-backed / uploaded bill documents and the reconciliation workflow (resolving a `document_id`). Filters by vendor, date, amount, and tax. Cursor pagination: pass `cursor` (or the legacy `page`) and follow the response's `next_cursor`; `limit` (1-100) is the standard alias of the legacy `page_size` (max 200). The effective page is capped at 100 rows, so a larger `page_size` is accepted but returns at most 100; `next_cursor` always round-trips, and `next_page` is also emitted for legacy callers.\n\nWhen a dated vendor search returns no bills, the response surfaces `searched_date_range` and up to 10 `available_bills_from_vendor` (the same vendor's bills in other periods) so callers can offer accruals or missing-bill review instead of a dead end.\nFor MCP callers, every page includes `page_summary`. If `financial_fields_complete` is false, a summed amount is only the total of bills with stated amount and currency; call out `incomplete_financial_record_count` — it always reconciles as `incomplete_bill_ids` length + `incomplete_without_id_count`, with `unparseable_row_count` covering rows without a readable bill envelope — and follow pagination before making a whole-result claim. `page_summary` does not deduplicate records or change bill data.\n\nIMPORTANT: a tool error or an unavailable/failed response is NOT the same as an empty result. Never tell the user there are no bills based on a failed call. If the call errors, retry once with fewer or simpler filters (drop vendor/date/amount filters); if it still fails, tell the user the query failed rather than reporting zero bills.",
        "tags": ["Vault"],
        "x-mcp-financial-field-completeness": true,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Items per page (1-100, default 25). Standard alias of `page_size` (max 200)."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Opaque pagination cursor from a previous response's `next_cursor`. Advances to the next page; takes precedence over `page` when both are sent."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-based page number. Use the response's `next_page` to advance."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            },
            "description": "Items per page (1-200, default 25)."
          },
          {
            "name": "vendor_name",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Vendor name (substring match)."
          },
          {
            "name": "vendor_ids",
            "in": "query",
            "schema": { "type": "array", "items": { "type": "string" } },
            "style": "form",
            "explode": true,
            "description": "Comma-separated `vnd_<uuid>` ids."
          },
          {
            "name": "bill_number",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Bill number."
          },
          {
            "name": "bill_date_from",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Earliest bill date (YYYY-MM-DD)."
          },
          {
            "name": "bill_date_to",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Latest bill date (YYYY-MM-DD)."
          },
          {
            "name": "billing_period_start_from",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Earliest billing-period start (YYYY-MM-DD)."
          },
          {
            "name": "billing_period_end_to",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Latest billing-period end (YYYY-MM-DD)."
          },
          {
            "name": "currency",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Currency code (e.g. USD)."
          },
          {
            "name": "total_amount_min",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Minimum total amount."
          },
          {
            "name": "total_amount_max",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Maximum total amount."
          },
          {
            "name": "tax_amount_min",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Minimum tax amount."
          },
          {
            "name": "tax_amount_max",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Maximum tax amount."
          },
          {
            "name": "tax_rate_min",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Minimum tax rate."
          },
          {
            "name": "tax_rate_max",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Maximum tax rate."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated bills (each item is a bill envelope), plus empty-results context.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/VaultBillEnvelope"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"],
                      "description": "Opaque cursor for the next page (follow it to paginate); null on the last page or when a `page_size` > 100 was requested (use `next_page` then)."
                    },
                    "next_page": {
                      "type": ["integer", "null"]
                    },
                    "searched_date_range": {
                      "oneOf": [
                        {
                          "type": "object",
                          "properties": {
                            "from": {
                              "type": "string"
                            },
                            "to": {
                              "type": "string"
                            }
                          }
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "The date window searched, when the result was empty."
                    },
                    "available_bills_from_vendor": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/VaultBillEnvelope"
                      },
                      "description": "Up to 10 of the same vendor's bills in other periods, when the dated search was empty."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/vendors": {
      "get": {
        "operationId": "listVaultVendors",
        "summary": "List Vault vendors",
        "description": "Lists Vault vendors with aggregate `files_count` and `total_bills_amount`. Only a successful response with an empty `data` array means no Vault vendors matched; an error or unavailable response is not an empty result. Cursor pagination: pass `cursor` (or the legacy `page`) and follow the response's `next_cursor` to advance through all vendors; `limit` (or the legacy `page_size`) caps the page (1-100, default 25). This is the raw internal DIRECTORY, not billing analysis and not a live provider call. For live GL-provider vendors use `tools.gl.list_glvendors`; for vendor billing cadence / spend analytics use `tools.analytics.get_vendor_insights`.",
        "tags": ["Vault"],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Items per page (1-100, default 25). Standard alias of `page_size`."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Opaque pagination cursor from a previous response's `next_cursor`. Advances to the next page; takes precedence over `page` when both are sent."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-based page number. Use the response's `next_page` to advance."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Items per page (1-100, default 25)."
          },
          {
            "name": "vendor_name_regex",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Case-insensitive regex applied to the vendor name (e.g. `AWS|amazon`)."
          },
          {
            "name": "mapping_status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["mapped", "unmapped"]
            },
            "description": "Filter by GL-mapping state. `unmapped` = no default expense account mapped (replaces the legacy unmapped-vendors endpoint); `mapped` = has one. Omit for all."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated vendors with aggregate counts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/VaultVendor"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_page": {
                      "type": ["integer", "null"]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/vault/statements/{id}/import": {
      "post": {
        "operationId": "importVaultStatement",
        "deprecated": true,
        "summary": "Import Vault statement lines",
        "description": "Imports pending statement lines from a Vault bank statement (`bnk_<uuid>`) into the ledger as bank transactions. Returns inline import counts, or a queued job (`job_id`) when run asynchronously.\n\n`legal_entity_id` is required when the statement is not already linked to an account; the error message says so when it's missing. `transaction_ids` accepts both `bkl_<uuid>` and bare statement-line ids; omit to import all pending lines. Deprecated: superseded by the top-level `/v1/vault/statement-lines` resource (sunset 2027-01-01).\n\nThis is a write — it creates ledger transactions from the statement lines (deduped on re-run). Call it only when the user explicitly asks to import a statement; the `bnk_<uuid>` statement id comes from `tools.vault.list_bank_statements`.",
        "tags": ["Vault"],
        "parameters": [
          {
            "$ref": "#/components/parameters/PathId"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "transaction_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Statement-line ids to import (`bkl_<uuid>` or bare). Omit to import all pending lines."
                  },
                  "legal_entity_id": {
                    "type": "string",
                    "description": "Legal entity to assign imported transactions to (`le_<id>` or bare)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Inline import counts (synchronous).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "imported": {
                          "type": "integer"
                        },
                        "duplicates": {
                          "type": "integer"
                        },
                        "errors": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Import job queued (asynchronous).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_id": {
                          "type": ["string", "null"]
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/spam-tokens": {
      "get": {
        "operationId": "listSpamTokens",
        "summary": "List spam tokens",
        "description": "Lists the organization's contract-level spam block list — each entry is a (`chain`, `asset_type`, `raw_contract_address`) tuple whose matching transactions are flagged `is_spam`. Read-only. Filter with `chain` / `asset_type` (exact match) or `contract_prefix` (case-insensitive substring on the contract address). Soft-deleted entries are excluded. This is the ORG-WIDE block list, not a per-transaction flag: to see which individual transactions are currently flagged, list transactions with `spam_only=true`. Keyset-paginated newest-first: `limit` defaults to 25 (max 100); pass `cursor` for the next page and `include_count=true` for a server-side `total_count`.",
        "tags": ["Spam Tokens"],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "description": "Max rows to return (default 25, max 100)."
          },
          {
            "$ref": "#/components/parameters/CursorQuery"
          },
          {
            "name": "chain",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64
            },
            "description": "Exact-match chain identifier (e.g. `eth`, `sol`)."
          },
          {
            "name": "asset_type",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64
            },
            "description": "Exact-match asset type / token symbol."
          },
          {
            "name": "contract_prefix",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 128
            },
            "description": "Case-insensitive SUBSTRING match on `raw_contract_address` — use when you know the leading hex of a contract but not the full address. Minimum 2 characters."
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Opt in to a server-side `total_count` on the response (extra count query)."
          }
        ],
        "responses": {
          "200": {
            "description": "Keyset-paginated list of spam tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1SpamToken"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Present only when `include_count=true`."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createSpamTokens",
        "summary": "Add spam tokens (cascades to transactions, assets, and disposals)",
        "description": "Creates the submitted spam tokens, then triggers a **DESTRUCTIVE, ORG-WIDE cascade far beyond the tokens you pass**: it re-scans the organization's ENTIRE spam block list and applies the result — every matching not-yet-spam transaction is set `is_spam: true`, the assets those transactions created are soft-deleted, and their disposals are reversed. The whole cascade runs inside a single Mongo transaction — it either fully applies or fully rolls back — but there is no undo API beyond `DELETE /v1/spam-tokens`, which is itself a second full cascade. To hide a handful of transactions WITHOUT editing the org-wide block list, use `POST /v1/transactions/mark-spam` (`tools.transactions.bulk_mark_spam_transactions`) instead: it is per-transaction, capped at 50 ids, and directly reversible with `spam: false`.\n\n### Cascade ceiling\n\nThe scan refuses to start when it would touch more than **50,000** transactions (`SPAM_TOKEN_CASCADE_MAX`), returning `409` with `would_touch_count` and `max`. Split the token into narrower (chain × asset_type) entries or contact support to run a bulk cron. The count is cumulative across the org's whole block list, not just this request's tokens.\n\n### Posted-journal-entry gate\n\nReturns `409` with `blocked_transaction_count` when ANY matching transaction has a posted journal entry — **unpost those journal entries first** (`POST /v1/journal-entries/{id}/unpost`) and retry. Nothing is written when this fires.\n\n### Batch + idempotency\n\n1–200 tokens per call; exact duplicates within the request are collapsed. Re-marking a contract that was previously unmarked resurrects its soft-deleted row rather than inserting a duplicate, so repeat calls converge on the same block list. Pass `Idempotency-Key` to make the whole request replay-safe.\n\n### Key requirements\n\nRequires a write-scoped key (`write:spam-tokens`); read-only keys receive `403`.",
        "tags": ["Spam Tokens"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["spam_tokens"],
                "additionalProperties": false,
                "properties": {
                  "spam_tokens": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 200,
                    "description": "Contracts to block. 1-200 per call.",
                    "items": {
                      "type": "object",
                      "required": ["asset_type", "raw_contract_address", "chain"],
                      "additionalProperties": false,
                      "properties": {
                        "asset_type": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 128
                        },
                        "raw_contract_address": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 256
                        },
                        "chain": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 64
                        }
                      }
                    }
                  }
                }
              },
              "example": {
                "spam_tokens": [
                  {
                    "asset_type": "SCAMCOIN",
                    "raw_contract_address": "0x0000000000000000000000000000000000000001",
                    "chain": "eth"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tokens upserted and the cascade applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["transactions_marked"],
                      "properties": {
                        "transactions_marked": {
                          "type": "integer",
                          "description": "How many transactions this call flagged `is_spam`. Counts the cascade over the org's WHOLE block list, not only the tokens in this request."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "Cascade refused. Either the scan exceeds the 50,000-transaction ceiling (`would_touch_count`, `max`) or a matching transaction has a posted journal entry (`blocked_transaction_count`) — unpost first. Nothing was written."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteSpamTokens",
        "summary": "Remove spam tokens (rolls back the flag, assets, and disposals)",
        "description": "Deletes the listed spam tokens — the **DESTRUCTIVE inverse of `POST /v1/spam-tokens`**. Soft-deletes the tokens and then rolls back everything they flagged: clears `is_spam` on every transaction linked to those tokens, restores the assets that were soft-deleted by the original cascade, and restores the reversed disposals. Like the create path, the whole rollback runs in one Mongo transaction. Transactions those tokens never flagged are untouched. To un-flag individual transactions while leaving the block list intact, use `POST /v1/transactions/mark-spam` with `spam: false` (`tools.transactions.bulk_mark_spam_transactions`).\n\n### Cascade ceiling\n\nRefuses to start when the rollback would touch more than **50,000** transactions (`SPAM_TOKEN_CASCADE_MAX`, shared with the create path), returning `409` with `would_touch_count` and `max`.\n\n### Posted-journal-entry gate\n\nReturns `409` with `blocked_transaction_count` when any transaction being un-flagged has a posted journal entry — **unpost those journal entries first** (`POST /v1/journal-entries/{id}/unpost`). Without this gate, restoring assets and disposals under a live posted entry would silently drift the general ledger from the vault. Nothing is written when this fires.\n\n### Batch + scoping\n\n1-200 ids per call. Ids belonging to another organization are ignored rather than erroring, so `tokens_deleted` may be lower than the number of ids you sent. Pass `Idempotency-Key` to make the request replay-safe.\n\n### Key requirements\n\nRequires a write-scoped key (`write:spam-tokens`); read-only keys receive `403`.",
        "tags": ["Spam Tokens"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "additionalProperties": false,
                "properties": {
                  "ids": {
                    "type": "array",
                    "maxItems": 200,
                    "description": "Prefixed `spt_` ids to remove (max 200). Ids outside your organization are silently skipped.",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              },
              "example": {
                "ids": ["spt_64f1a2b3c4d5e6f708192a3b"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tokens soft-deleted and the rollback applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["tokens_deleted", "transactions_unmarked"],
                      "properties": {
                        "tokens_deleted": {
                          "type": "integer",
                          "description": "Tokens actually removed — excludes ids not owned by your organization."
                        },
                        "transactions_unmarked": {
                          "type": "integer",
                          "description": "Transactions whose `is_spam` flag was cleared by this rollback."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "Rollback refused. Either it exceeds the 50,000-transaction ceiling (`would_touch_count`, `max`) or a transaction being un-flagged has a posted journal entry (`blocked_transaction_count`) — unpost first. Nothing was written."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/spam-tokens/{id}": {
      "get": {
        "operationId": "getSpamToken",
        "summary": "Get a spam token",
        "description": "Returns a single spam-token entry by its `spt_` id. Read-only. Responds `404` when the token does not exist, is soft-deleted, or belongs to another organization. Use `tools.spam_tokens.list_spam_tokens` to enumerate the block list, or list transactions with `spam_only=true` to see which transactions a token is currently hiding.",
        "tags": ["Spam Tokens"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `spt_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The spam token.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1SpamToken"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/whitelist-tokens": {
      "get": {
        "operationId": "listWhitelistTokens",
        "summary": "List whitelist tokens",
        "description": "Lists the organization's contract-level allow list — the inverse of the spam block list (`tools.spam_tokens.list_spam_tokens`). Read-only. **While the org has at least one whitelist entry, transaction ingestion keeps ONLY transactions whose (`chain`, `asset_type`, `address`) tuple appears here**; an empty allow list means everything is ingested. Filter with `chain` (enum) / `asset_type` (exact) or `address_prefix` (case-insensitive ANCHORED prefix match, unlike the spam list's substring match). Soft-deleted entries are excluded. Keyset-paginated newest-first: `limit` defaults to 25 (max 100); pass `cursor` for the next page and `include_count=true` for a server-side `total_count`.",
        "tags": ["Whitelist Tokens"],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "description": "Max rows to return (default 25, max 100)."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 1024
            },
            "description": "Keyset cursor from a previous page's `next_cursor`."
          },
          {
            "name": "chain",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "eth",
                "etc",
                "btc",
                "ltc",
                "polygon",
                "sol",
                "bsc",
                "avalanche",
                "near",
                "arb",
                "op",
                "ftm",
                "base",
                "algo",
                "flow",
                "hbar",
                "noble",
                "xlm",
                "trx",
                "linea",
                "scroll",
                "zora",
                "zksync",
                "celo",
                "ronin",
                "xrp",
                "cardano",
                "celestia",
                "polygon-zkevm",
                "cro",
                "dot",
                "babylon",
                "pah",
                "doge",
                "bera",
                "statemint",
                "ton",
                "neutron",
                "katana",
                "hyperevm",
                "cosmos",
                "sui",
                "canton",
                "worldchain"
              ]
            },
            "description": "Chain identifier — validated against the supported wallet-chain enum (e.g. `eth`, `sol`), so an unsupported value returns 400 rather than matching nothing."
          },
          {
            "name": "asset_type",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 128
            },
            "description": "Exact-match asset type / token symbol."
          },
          {
            "name": "address_prefix",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 256
            },
            "description": "Case-insensitive ANCHORED prefix match on `address` (matches from the start, unlike the spam list's `contract_prefix` substring match). Minimum 2 characters."
          },
          {
            "name": "include_count",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Opt in to a server-side `total_count` on the response (extra count query)."
          }
        ],
        "responses": {
          "200": {
            "description": "Keyset-paginated list of whitelist tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_cursor"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1WhitelistToken"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": ["string", "null"]
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Present only when `include_count=true`."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createWhitelistToken",
        "summary": "Add a whitelist token (switches ingestion to deny-by-default)",
        "description": "Adds one contract to the organization's ingestion allow list. **Creating the FIRST whitelist entry flips the whole organization from ingest-everything to deny-by-default**: from that point on, ingestion keeps only transactions whose (`chain`, `asset_type`, `address`) tuple is on this list, and every other incoming transaction is silently dropped at ingestion. Confirm that switch with the user before the first call on an org whose list is currently empty (`tools.whitelist_tokens.list_whitelist_tokens` returns an empty page).\n\n### Not a cascade\n\nUnlike `POST /v1/spam-tokens`, this does NOT touch existing data — no transaction is flagged, no asset is soft-deleted, no disposal is reversed. The allow list is read at ingestion time only, so it changes what arrives in future, never what is already stored.\n\n### Uniqueness and reversal\n\nEach (chain, asset_type, address) tuple should appear once. Remove an entry with `DELETE /v1/whitelist-tokens/{id}` — a soft delete that preserves the audit trail. Removing the LAST entry returns the org to ingest-everything.\n\n### Key requirements\n\nRequires a write-scoped key (`write:whitelist-tokens`) that is owned by a user: `created_by` / `updated_by` are stamped from the key's owner, so read-only keys and ownerless organization-generated keys receive `403`.",
        "tags": ["Whitelist Tokens"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["chain", "asset_type", "address"],
                "additionalProperties": false,
                "properties": {
                  "chain": {
                    "type": "string",
                    "enum": [
                      "eth",
                      "etc",
                      "btc",
                      "ltc",
                      "polygon",
                      "sol",
                      "bsc",
                      "avalanche",
                      "near",
                      "arb",
                      "op",
                      "ftm",
                      "base",
                      "algo",
                      "flow",
                      "hbar",
                      "noble",
                      "xlm",
                      "trx",
                      "linea",
                      "scroll",
                      "zora",
                      "zksync",
                      "celo",
                      "ronin",
                      "xrp",
                      "cardano",
                      "celestia",
                      "polygon-zkevm",
                      "cro",
                      "dot",
                      "babylon",
                      "pah",
                      "doge",
                      "bera",
                      "statemint",
                      "ton",
                      "neutron",
                      "katana",
                      "hyperevm",
                      "cosmos",
                      "sui",
                      "canton",
                      "worldchain"
                    ],
                    "description": "Chain identifier — validated against the supported wallet-chain enum; an unsupported value returns 400.",
                    "examples": ["eth", "sol"]
                  },
                  "asset_type": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 128
                  },
                  "address": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256
                  }
                }
              },
              "example": {
                "chain": "eth",
                "asset_type": "USDC",
                "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created whitelist token.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1WhitelistToken"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "bulkDeleteWhitelistTokens",
        "summary": "Remove whitelist tokens in bulk",
        "description": "Soft-deletes up to 200 whitelist entries in one call, narrowing which transactions future ingestion will accept. The rows are retained with an audit trail (`created_by` / `updated_by` / timestamps) rather than hard-deleted, but they stop filtering immediately — the ingestion allow-list query excludes soft-deleted rows exactly as a hard delete would. **Removing the LAST remaining entry returns the organization to ingest-everything**, which can be a much larger behavior change than the number of ids suggests; check the remaining count with `tools.whitelist_tokens.list_whitelist_tokens` first.\n\n### Not retroactive\n\nDoes NOT re-ingest transactions that were previously filtered out, and does not touch transactions, assets, or disposals — the allow list is applied at ingestion time only. Contrast `DELETE /v1/spam-tokens`, which does cascade over existing data.\n\n### Scoping\n\nIds belonging to another organization, or that don't exist at all, are silently ignored, so `deleted_count` may be lower than the number of ids you sent — it is not an error signal. Pass `Idempotency-Key` to make the request replay-safe. Requires a write-scoped key (`write:whitelist-tokens`); read-only keys receive `403`.",
        "tags": ["Whitelist Tokens"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "additionalProperties": false,
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 200,
                    "description": "Prefixed `wlt_` ids to remove (1-200). Ids outside your organization are silently skipped.",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              },
              "example": {
                "ids": ["wlt_64f1a2b3c4d5e6f708192a3b"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Soft-delete applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted_count"],
                      "properties": {
                        "deleted_count": {
                          "type": "integer",
                          "description": "Rows actually soft-deleted — excludes ids not owned by your organization."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/whitelist-tokens/{id}": {
      "get": {
        "operationId": "getWhitelistToken",
        "summary": "Get a whitelist token",
        "description": "Returns a single allow-list entry by its `wlt_` id and sets an `ETag` header, which is the value `PATCH /v1/whitelist-tokens/{id}` requires in `If-Match`. Read-only. Responds `404` when the token does not exist, is soft-deleted, or belongs to another organization.",
        "tags": ["Whitelist Tokens"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `wlt_` id."
          }
        ],
        "responses": {
          "200": {
            "description": "The whitelist token. The `ETag` response header carries the concurrency token for a subsequent PATCH.",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Current entity tag — pass as `If-Match` on PATCH."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1WhitelistToken"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "patch": {
        "operationId": "updateWhitelistToken",
        "summary": "Edit a whitelist token",
        "description": "Updates a whitelist entry's `chain`, `asset_type`, and/or `address`, changing which transactions future ingestion accepts. Not retroactive — existing transactions, assets, and disposals are untouched.\n\n### If-Match is required\n\nOptimistic concurrency is mandatory: send `If-Match` set to the `ETag` returned by `GET /v1/whitelist-tokens/{id}`. A missing or stale header returns `412` so a concurrent edit cannot be silently clobbered. Clients that do not manage ETags should re-create the entry instead (`DELETE` then `POST /v1/whitelist-tokens`).\n\n### Partial update\n\nAt least one of `chain`, `asset_type`, `address` must be present; omitted fields keep their current value. `updated_by` is stamped from the API key's owning user, so read-only and ownerless keys receive `403`.",
        "tags": ["Whitelist Tokens"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `wlt_` id."
          },
          {
            "name": "If-Match",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "ETag from `GET /v1/whitelist-tokens/{id}`. Required for optimistic concurrency; absent or stale returns 412."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "additionalProperties": false,
                "description": "At least one field must be present.",
                "properties": {
                  "chain": {
                    "type": "string",
                    "enum": [
                      "eth",
                      "etc",
                      "btc",
                      "ltc",
                      "polygon",
                      "sol",
                      "bsc",
                      "avalanche",
                      "near",
                      "arb",
                      "op",
                      "ftm",
                      "base",
                      "algo",
                      "flow",
                      "hbar",
                      "noble",
                      "xlm",
                      "trx",
                      "linea",
                      "scroll",
                      "zora",
                      "zksync",
                      "celo",
                      "ronin",
                      "xrp",
                      "cardano",
                      "celestia",
                      "polygon-zkevm",
                      "cro",
                      "dot",
                      "babylon",
                      "pah",
                      "doge",
                      "bera",
                      "statemint",
                      "ton",
                      "neutron",
                      "katana",
                      "hyperevm",
                      "cosmos",
                      "sui",
                      "canton",
                      "worldchain"
                    ],
                    "description": "Chain identifier — validated against the supported wallet-chain enum."
                  },
                  "asset_type": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 128
                  },
                  "address": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256
                  }
                }
              },
              "example": {
                "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated whitelist token.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1WhitelistToken"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "412": {
            "description": "If-Match precondition failed — the header was absent or did not match the row's current ETag. Re-read with `GET /v1/whitelist-tokens/{id}` and retry."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteWhitelistToken",
        "summary": "Remove a whitelist token",
        "description": "Soft-deletes one allow-list entry, narrowing which transactions future ingestion accepts. The row is retained with its audit trail (`created_by` / `updated_by` / timestamps) rather than hard-deleted, but it stops filtering immediately. **Removing the LAST remaining entry returns the organization to ingest-everything** — check the remaining count with `tools.whitelist_tokens.list_whitelist_tokens` before deleting the final row.\n\nNot retroactive: previously filtered transactions are not re-ingested, and no transaction, asset, or disposal is modified. Responds `404` when the token does not exist, was already deleted, or belongs to another organization. Requires a write-scoped key (`write:whitelist-tokens`).",
        "tags": ["Whitelist Tokens"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `wlt_` id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The token was soft-deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "deleted": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-application/matches": {
      "get": {
        "operationId": "listCashApplicationMatches",
        "summary": "List cash-application matches (deprecated — use /v1/cash-applications)",
        "description": "Lists cash-application matches. **Deprecated** in favor of `GET /v1/cash-applications` (`tools.cash_applications.list_cash_applications`), the resource-shaped successor over the same underlying collection with a cursor envelope and an `allocations` contract. This endpoint stays REST-documented for existing integrations but responses carry `Deprecation`/`Sunset` headers.\n\nRead-only, page-based pagination (not cursor). Filter by `status`; the response also includes `status_counts`, a full status-breakdown aggregate scoped to the org, computed independently of the `status` filter — so you can see the whole pipeline's shape in one call even while paginating one status.",
        "tags": ["Cash Applications"],
        "deprecated": true,
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "proposed",
                "applied_pending_stripe",
                "completed",
                "rejected",
                "expired",
                "exception",
                "in_progress",
                "failed"
              ]
            },
            "description": "Filter to one status. `in_progress` and `failed` are back-compat aliases mapped to `pending`/`exception` respectively."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-based page number."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Page size (1-100)."
          }
        ],
        "responses": {
          "200": {
            "description": "Page-based list of matches, plus an org-wide status breakdown.",
            "headers": {
              "Deprecation": {
                "schema": {
                  "type": "string"
                },
                "description": "\"true\" — this endpoint is deprecated."
              },
              "Sunset": {
                "schema": {
                  "type": "string"
                },
                "description": "RFC 8594 sunset date."
              },
              "Link": {
                "schema": {
                  "type": "string"
                },
                "description": "Points at the successor resource."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page", "status_counts"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1CashApplicationMatch"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_page": {
                      "type": ["integer", "null"]
                    },
                    "status_counts": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      },
                      "description": "Count of matches per status across the ENTIRE org, independent of the `status` filter above."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-application/matches/{id}/confirm": {
      "post": {
        "operationId": "confirmCashApplicationMatch",
        "summary": "Confirm a proposed match (deprecated — use /v1/cash-applications/{id}/apply)",
        "description": "Marks a `proposed` match confirmed by EITHER linking a journal entry you already posted (`journal_entry_id`) OR letting the endpoint auto-apply using `compiled_rules` from your org-memory — exactly one of the two is required. **Deprecated** in favor of `POST /v1/cash-applications/{id}/apply`, which shares the same underlying validation and write path. Manual linking validates the JE's status, balance, bank-touch, and customer tag before writing, and rejects a JE already linked to another match.",
        "tags": ["Cash Applications"],
        "deprecated": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `cam_` match id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "description": "Provide journal_entry_id (manual link) OR compiled_rules (auto-apply) — one is required.",
                "properties": {
                  "journal_entry_id": {
                    "type": "string",
                    "description": "Prefixed `je_` id of an already-posted journal entry to link manually."
                  },
                  "compiled_rules": {
                    "type": "array",
                    "description": "Compiled cash-application rules from your org-memory, required unless journal_entry_id is supplied.",
                    "items": {
                      "$ref": "#/components/schemas/V1CashApplicationCompiledRule"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The match was confirmed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["match_id", "journal_entry_id", "linked_manually"],
                      "properties": {
                        "match_id": {
                          "type": "string"
                        },
                        "journal_entry_id": {
                          "type": "string"
                        },
                        "journal_sequence_number": {
                          "type": ["string", "null"]
                        },
                        "linked_manually": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-application/matches/{id}/reject": {
      "post": {
        "operationId": "rejectCashApplicationMatch",
        "summary": "Reject a match (deprecated — use /v1/cash-applications/{id}/void)",
        "description": "Rejects a `proposed` or `applied_pending_stripe` match — deprecated in favor of `POST /v1/cash-applications/{id}/void`. **For an `applied_pending_stripe` match this first unposts the linked journal entry, then marks the match `rejected`** — the unpost happens automatically, you do not call it separately, and the operation preserves the reversal in the JE audit trail (unpost, not delete). If the unpost fails, the match and deposit are left untouched so you can act on the real error.\n\nA `pending` match can be rejected ONLY when it has no linked journal entry yet (a stranded claim from an interrupted request) — a `pending` match that already has a journal entry is mid-commit and stays untouchable until it resolves on its own.",
        "tags": ["Cash Applications"],
        "deprecated": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `cam_` match id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Optional free-text rejection reason."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The match was rejected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["match_id", "status", "unposted_journal_entry"],
                      "properties": {
                        "match_id": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string",
                          "enum": ["rejected"]
                        },
                        "unposted_journal_entry": {
                          "type": "boolean",
                          "description": "True when a linked journal entry was automatically unposted as part of this rejection."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The match is not in a rejectable status (must be `proposed`, `applied_pending_stripe`, or a stranded `pending` claim with no linked JE)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-application/matches/{id}/approve": {
      "post": {
        "operationId": "approveCashApplicationMatch",
        "summary": "Approve a pending-Stripe match (deprecated — use /v1/cash-applications/{id}/complete)",
        "description": "Approves an `applied_pending_stripe` match — the human-in-the-loop step when `require_stripe_approval` is enabled — by marking the matched Stripe invoice(s) paid via the Stripe API and transitioning the match to `completed`. Deprecated in favor of `POST /v1/cash-applications/{id}/complete` — **use this endpoint only until that successor is available to you**; it is kept callable specifically because the successor is not currently exposed as an agent tool, not because it's the preferred long-term path. Only callable on a match currently in `applied_pending_stripe`; any other status returns 400. A Stripe API failure surfaces as 502 and leaves the match in its prior state for retry.",
        "tags": ["Cash Applications"],
        "deprecated": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `cam_` match id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The match was completed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["match_id", "status", "paid_invoices"],
                      "properties": {
                        "match_id": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string",
                          "enum": ["completed"]
                        },
                        "paid_invoices": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Match is not in `applied_pending_stripe` status."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Stripe API error while marking invoice(s) paid. The match was left in its prior state — retry."
          }
        }
      }
    },
    "/cash-application/sweep": {
      "post": {
        "operationId": "triggerCashApplicationSweep",
        "summary": "Run the cash-application matching sweep",
        "description": "Triggers the same deposit-matching sweep the scheduled cron runs, on demand: walks unprocessed deposits, matches each against open (then paid) Stripe AR invoices, and — outside `dry_run` — writes match rows and posts journal entries for high-confidence matches. Defaults to the last 7 days of deposits when `date_from`/`date_to` are omitted.\n\n### dry_run is a real preview\n\n`dry_run: true` runs the full matching logic and reports what WOULD happen (`applied` / `proposed` / `exceptions` per deposit) without writing anything — no match rows, no journal entries. Always preview before a live sweep on an org you haven't run this against before.\n\n### compiled_rules is required for a live sweep\n\n`compiled_rules` (your org-memory cash-application rules) is optional for `dry_run` but **required** when actually sweeping — posting with an empty rule set would silently drop `route_to_account` / `require_human_review` controls that route ambiguous deposits to review instead of auto-posting them. Pass `[]` explicitly to opt into default routing with no rules.\n\nEach deposit is processed independently; a per-deposit failure is recorded in `results[]` with `status: \"error\"` and does not abort the rest of the batch.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "dry_run": {
                    "type": "boolean",
                    "description": "Preview only — no match rows or journal entries are written. Default false."
                  },
                  "date_from": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "date_to": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "compiled_rules": {
                    "type": "array",
                    "description": "Required unless dry_run is true. Pass [] to opt into default routing explicitly.",
                    "items": {
                      "$ref": "#/components/schemas/V1CashApplicationCompiledRule"
                    }
                  }
                }
              },
              "example": {
                "dry_run": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sweep results.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["dry_run", "processed", "applied", "proposed", "exceptions", "skipped", "results"],
                      "properties": {
                        "dry_run": {
                          "type": "boolean"
                        },
                        "processed": {
                          "type": "integer"
                        },
                        "applied": {
                          "type": "integer"
                        },
                        "proposed": {
                          "type": "integer"
                        },
                        "exceptions": {
                          "type": "integer"
                        },
                        "skipped": {
                          "type": "integer",
                          "description": "Candidates already matched (retried unless a retryable state applies)."
                        },
                        "filtered_ineligible": {
                          "type": "integer"
                        },
                        "results": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "transaction_id": {
                                "type": ["string", "null"]
                              },
                              "sequence_number": {
                                "type": ["string", "null"]
                              },
                              "transaction_date": {
                                "type": ["string", "null"]
                              },
                              "amount": {
                                "type": "number"
                              },
                              "memo": {
                                "type": "string"
                              },
                              "status": {
                                "type": "string",
                                "enum": ["applied", "proposed", "exception", "already_claimed", "error"]
                              },
                              "counterparty_name": {
                                "type": ["string", "null"]
                              },
                              "stripe_customer_name": {
                                "type": ["string", "null"]
                              },
                              "invoice_number": {
                                "type": ["string", "null"]
                              },
                              "invoice_status": {
                                "type": ["string", "null"],
                                "enum": ["open", "paid", null]
                              },
                              "journal_sequence_number": {
                                "type": ["string", "null"]
                              },
                              "error_message": {
                                "type": ["string", "null"]
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Cash application is not enabled for this org, no Plaid account is configured, or compiled_rules was omitted on a non-dry_run sweep."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-application/apply": {
      "post": {
        "operationId": "applyCashApplicationDeposit",
        "summary": "Apply a specific deposit to invoice(s)",
        "description": "Applies one deposit to one or more Stripe AR invoices you have already chosen, posting the corresponding journal entry. **MUTATING.** This is the targeted counterpart to `POST /v1/cash-application/sweep` (which matches automatically) — use this when a human or agent has already decided the deposit/invoice pairing, e.g. after `tools.cash_applications.search_cash_application_invoices` returned the right invoice.\n\n### Flexible references — not prefixed ids\n\n`deposit` accepts a sequence number, a `uniqueId`, or a raw Mongo `_id` — NOT a `txn_`-prefixed id. `invoices` accepts invoice numbers, Stripe invoice ids, or raw Mongo ids. Both are resolved server-side; an unresolvable reference returns 404 naming the exact value.\n\n### Eligibility and rules\n\nThe deposit must pass the same eligibility check the sweep uses (not already claimed, correct source account, not a duplicate Stripe-payout mirror). `compiled_rules` (your org-memory cash-application rules) is REQUIRED — this endpoint no longer reads rules from a backend cache. `adjustment_ledger_account_id` (optional) must be a postable leaf account in your organization; supply it to absorb a rounding/adjustment residual instead of the default account.\n\nShares its orchestration (claim → apply → JE post) with `POST /v1/cash-applications/{id}/apply`, so the two paths can never drift on business logic.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["deposit", "invoices", "compiled_rules"],
                "additionalProperties": false,
                "properties": {
                  "deposit": {
                    "type": "string",
                    "description": "Sequence number, uniqueId, or raw Mongo _id of the deposit transaction — NOT a txn_-prefixed id."
                  },
                  "invoices": {
                    "type": "array",
                    "minItems": 1,
                    "description": "Invoice numbers, Stripe invoice ids, or raw Mongo ids to allocate against.",
                    "items": {
                      "type": "string",
                      "minLength": 1
                    }
                  },
                  "adjustment_ledger_account_id": {
                    "type": "string",
                    "description": "Prefixed `lac_` postable leaf account to absorb a rounding/adjustment residual."
                  },
                  "compiled_rules": {
                    "type": "array",
                    "description": "Required. Compiled cash-application rules from your org-memory.",
                    "items": {
                      "$ref": "#/components/schemas/V1CashApplicationCompiledRule"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The deposit was applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["match_id", "journal_entry_id", "deposit", "invoices", "invoice_status"],
                      "properties": {
                        "match_id": {
                          "type": "string"
                        },
                        "journal_entry_id": {
                          "type": "string"
                        },
                        "journal_sequence_number": {
                          "type": ["string", "null"]
                        },
                        "deposit": {
                          "type": "string"
                        },
                        "invoices": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "invoice_status": {
                          "type": "string",
                          "enum": ["open", "paid"]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "The deposit or one of the invoice references could not be resolved — the message names the exact value."
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cash-application/learn-alias": {
      "post": {
        "operationId": "recordCashApplicationAlias",
        "summary": "Record a learned wire-memo alias",
        "description": "Adds a learned wire-memo alias, teaching the cash-application matcher that a recurring wire-memo token (e.g. a truncated or misspelled customer name banks send in ACH/wire memos) refers to a specific Stripe customer, so future deposits carrying that memo token resolve to the right customer automatically. Idempotent — recording the same (customer, memo_token) pair again is a no-op re-confirmation, not an error.\n\nShares its validation and cross-org checks with the legacy `/cash-application/learn-alias` route via one service function, so this endpoint and the in-app copilot's alias learning can never drift.",
        "tags": ["Cash Applications"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["stripe_customer_id", "customer_name", "memo_token"],
                "additionalProperties": false,
                "properties": {
                  "stripe_customer_id": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256
                  },
                  "customer_name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256
                  },
                  "memo_token": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 256,
                    "description": "The recurring wire/ACH memo fragment to associate with this customer. A minimum-length floor (4 chars) is enforced server-side to avoid learning overly generic tokens."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Alias learned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["stripe_customer_id", "customer_name", "memo_token", "message"],
                      "properties": {
                        "stripe_customer_id": {
                          "type": "string"
                        },
                        "customer_name": {
                          "type": "string"
                        },
                        "memo_token": {
                          "type": "string"
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation failure from the shared learn-alias service (e.g. memo_token below the 4-character floor)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "stripe_customer_id does not resolve to a known Stripe AR customer in this organization."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/attachments": {
      "get": {
        "operationId": "listJournalEntryAttachments",
        "summary": "List a journal entry's attachments",
        "description": "Lists the files attached to a journal entry — supporting documentation such as invoices, receipts, or contracts — with freshly generated signed read URLs on every call. Read-only. Returns 404 when the journal entry does not exist or belongs to another organization. Excludes attachments that were detached with `DELETE /v1/journal-entries/{id}/attachments/{attachment_id}`.\n\nSigned URLs expire; re-call this endpoint to refresh them rather than caching a URL long-term.",
        "tags": ["Journal Entries"],
        "x-mcp-complete-collection": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `je_` journal-entry id."
          }
        ],
        "responses": {
          "200": {
            "description": "The journal entry's attachments.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1JournalEntryAttachment"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "post": {
        "operationId": "createJournalEntryAttachment",
        "summary": "Attach a file to a journal entry",
        "description": "Uploads or links a supporting file to a journal entry — either raw bytes or an existing vault document — provide exactly one arm, never both. A linked vault document's bytes are copied into a dedicated organization-files location, so the journal-entry attachment is self-contained and deleting it later never touches the original vault document.\n\n### Upload arm\n\n`filename` + `base64`. The extension must be in the platform's allowed attachment list (PDF, common image formats, etc. — an unsupported extension returns 400); base64 payload is capped at roughly 10 MB decoded (~14 MB encoded).\n\n### Link arm\n\n`document_id` — a prefixed `doc_` id of an existing vault document in your organization.\n\nThe journal entry must exist in your organization (404 otherwise). Returns 201 with a freshly signed read URL for the new attachment.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `je_` journal-entry id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "type": "object",
                    "required": ["filename", "base64"],
                    "additionalProperties": false,
                    "properties": {
                      "filename": {
                        "type": "string",
                        "maxLength": 255,
                        "description": "Must end in an allowed extension (PDF, common image formats, etc.)."
                      },
                      "base64": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 14680064,
                        "description": "Base64-encoded file bytes. Capped at roughly 14 MB encoded (~10 MB decoded)."
                      }
                    }
                  },
                  {
                    "type": "object",
                    "required": ["document_id"],
                    "additionalProperties": false,
                    "properties": {
                      "document_id": {
                        "type": "string",
                        "description": "Prefixed `doc_` id of an existing vault document in your organization. Its bytes are copied, not referenced."
                      }
                    }
                  }
                ]
              },
              "examples": {
                "link_existing_document": {
                  "value": {
                    "document_id": "doc_64f1a2b3c4d5e6f708192a3b"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The attachment was created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/V1JournalEntryAttachment"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation failure — both arms supplied, neither arm supplied, disallowed file extension, or oversized payload."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The journal entry does not exist in your organization, or (link arm) document_id does not resolve to a vault document in your organization."
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "description": "The uploaded image carries location or camera metadata that could not be removed. Convert it to JPEG or PNG and retry; the reason is reported in error.reason."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/journal-entries/{id}/attachments/{attachment_id}": {
      "delete": {
        "operationId": "deleteJournalEntryAttachment",
        "summary": "Detach a file from a journal entry",
        "description": "Soft-deletes the attachment link and its organization-file record — the underlying GCS blob is intentionally left in place, both to avoid accidental data loss and because a linked/copied blob is never exclusively this attachment's to delete. There is no separate byte-level delete; detaching is the only lifecycle operation this endpoint offers.\n\nReturns 404 when the attachment does not exist, was already detached, or belongs to a different journal entry or organization.",
        "tags": ["Journal Entries"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `je_` journal-entry id."
          },
          {
            "name": "attachment_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `jea_` attachment id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The attachment was detached.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "deleted"],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "deleted": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/webhooks/{id}/deliveries": {
      "get": {
        "operationId": "listWebhookDeliveries",
        "summary": "List a webhook subscription's delivery history",
        "description": "Returns the delivery attempt history for one webhook subscription — status, attempt count, last HTTP response code, and last error message — so you can debug failed or retrying deliveries without database access. Read-only, page-based pagination. **Does NOT include the delivered payload body** (payloads can run to ~50 KB for transaction events); re-derive the payload from the source resource using `event_id` plus that resource's own id if you need to see what was sent.\n\nFilter with `status` (exact match) and `event_type` (exact match). Returns 404 when the subscription does not exist or belongs to another organization.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `whk_` webhook-subscription id."
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-based page number."
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Page size (default 25, max 100)."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["pending", "in_progress", "succeeded", "failed", "failed_terminal"]
            },
            "description": "Exact-match delivery status. Case-insensitive on input."
          },
          {
            "name": "event_type",
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            },
            "description": "Exact-match event type (e.g. `journal_entry.posted`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Page-based delivery history.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "has_more", "next_page"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V1WebhookDelivery"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_page": {
                      "type": ["integer", "null"]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "The webhook subscription does not exist in your organization. Note: unlike most v1 404s this message does not echo the id."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/organizations/purpose-accounts": {
      "get": {
        "operationId": "listOrganizationPurposeAccounts",
        "summary": "List purpose-typed ledger account bindings",
        "description": "Returns the ledger account currently bound to each fixed accounting purpose (e.g. `REALIZED_GAIN_LOSS`, `GAS_FEE`, `SWAP_CLEARING`) — one row per purpose, always, with `ledger_account_id: null` when unset. These bindings are what `POST /v1/journal-entries` (classification JE creation) consults for crypto-disposal accounts before falling back to a fuzzy name match; setting one here makes that lookup deterministic instead of best-effort. Read-only.",
        "tags": ["Organizations"],
        "x-mcp-complete-collection": true,
        "responses": {
          "200": {
            "description": "One row per purpose type.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["purpose", "ledger_account_id"],
                        "properties": {
                          "purpose": {
                            "type": "string",
                            "enum": [
                              "DIGITAL_ASSETS",
                              "SWAP_CLEARING",
                              "BRIDGE_CLEARING",
                              "INTERNAL_TRANSFER_CLEARING",
                              "INTERCOMPANY_TRANSFER_CLEARING",
                              "GAS_FEE",
                              "REALIZED_GAIN_LOSS",
                              "TOTAL_EXPENSE"
                            ]
                          },
                          "ledger_account_id": {
                            "type": ["string", "null"]
                          },
                          "sequence_number": {
                            "type": ["integer", "null"]
                          },
                          "ledger_account_name": {
                            "type": ["string", "null"]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "put": {
        "operationId": "setOrganizationPurposeAccount",
        "summary": "Bind a ledger account to an accounting purpose",
        "description": "Sets which ledger account is bound to one of the fixed accounting purposes (upsert — both fields are required and `purpose` is case-sensitive UPPERCASE). **Replaces any prior binding for this purpose**: earlier bindings are deleted, not layered, so the lookup resolves deterministically instead of picking arbitrarily among duplicates. The target account must be a postable **leaf** account in your organization (400 otherwise).\n\nThe most-consulted purpose today is `REALIZED_GAIN_LOSS`, which crypto-disposal journal-entry creation reads to avoid its fuzzy-name-match fallback. Repeated PUTs with the same purpose are idempotent by replacement. Mutations require a user-owned key.",
        "tags": ["Organizations"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["purpose", "ledger_account_id"],
                "additionalProperties": false,
                "properties": {
                  "purpose": {
                    "type": "string",
                    "enum": [
                      "DIGITAL_ASSETS",
                      "SWAP_CLEARING",
                      "BRIDGE_CLEARING",
                      "INTERNAL_TRANSFER_CLEARING",
                      "INTERCOMPANY_TRANSFER_CLEARING",
                      "GAS_FEE",
                      "REALIZED_GAIN_LOSS",
                      "TOTAL_EXPENSE"
                    ],
                    "description": "Case-sensitive UPPERCASE."
                  },
                  "ledger_account_id": {
                    "type": "string",
                    "description": "Prefixed `lac_` id of a postable leaf account in your organization."
                  }
                }
              },
              "example": {
                "purpose": "REALIZED_GAIN_LOSS",
                "ledger_account_id": "lac_64f1a2b3c4d5e6f708192a3b"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The binding was set.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["purpose", "ledger_account_id"],
                      "properties": {
                        "purpose": {
                          "type": "string"
                        },
                        "ledger_account_id": {
                          "type": "string"
                        },
                        "sequence_number": {
                          "type": ["integer", "null"]
                        },
                        "ledger_account_name": {
                          "type": ["string", "null"]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "ledger_account_id is not a postable leaf account, or a schema violation (bad enum value, missing field, unknown field)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "ledger_account_id does not exist in your organization or is soft-deleted."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/cards/payment-accounts/{id}": {
      "get": {
        "operationId": "getCardPaymentAccount",
        "summary": "Get a card's payment-account mapping (deprecated — use /v1/cards/payment-accounts)",
        "description": "Gets a card's payment-account mapping. **Deprecated** — card payment-account mappings are actually an ORG-LEVEL setting, not per-card — every card in the organization shares the same `card_payment_ledger_account_id` / `bill_payment_ledger_account_id` pair. This `{id}` form exists only for one release of backward compatibility; use the singleton `GET /v1/cards/payment-accounts` (`tools.cards.get_card_payment_accounts`), which returns the identical mapping without implying a per-card scope. The `id` path segment is validated (must reference a card in your organization) but does not otherwise affect the response.",
        "tags": ["Cards"],
        "deprecated": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `crd_` card id — accepted for the deprecated URL shape but the mapping itself is org-wide."
          }
        ],
        "responses": {
          "200": {
            "description": "The org's card payment-account mapping.",
            "headers": {
              "Deprecation": {
                "schema": {
                  "type": "string"
                }
              },
              "Sunset": {
                "schema": {
                  "type": "string"
                }
              },
              "Link": {
                "schema": {
                  "type": "string"
                },
                "description": "Points at /v1/cards/payment-accounts."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["card_id", "card_payment_ledger_account_id", "bill_payment_ledger_account_id"],
                      "properties": {
                        "card_id": {
                          "type": "string"
                        },
                        "card_payment_ledger_account_id": {
                          "type": ["string", "null"]
                        },
                        "bill_payment_ledger_account_id": {
                          "type": ["string", "null"]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Upstream Ramp error while reading the mapping."
          }
        }
      },
      "patch": {
        "operationId": "updateCardPaymentAccount",
        "summary": "Update a card's payment-account mapping (deprecated — use /v1/cards/payment-accounts)",
        "description": "Updates a card's payment-account mapping. **Deprecated** — this mapping is actually ORG-LEVEL, not per-card — writing it here changes the SAME org-wide `card_payment_ledger_account_id` / `bill_payment_ledger_account_id` pair that `PATCH /v1/cards/payment-accounts` (the canonical singleton REST endpoint) writes. Prefer the singleton form; this `{id}` shape exists only for one release of backward compatibility, and the `id` path segment is validated (must reference a card in your organization) but does not scope the write to that card.\n\n### Tri-state fields\n\nEach of the two account fields is tri-state: omit the key to leave it unchanged, send `null` to explicitly clear it, or send a `lac_` id to set it. At least one field must carry a meaningful (non-undefined) value.\n\n### Known consistency gap\n\nThe underlying write updates the Postgres `ramp_connection` row and the Mongo card/bank-account records in two separate operations that are **not transactional across the two stores** — a mid-write failure can leave them briefly inconsistent. Documented, accepted risk; not fixed by this endpoint.",
        "tags": ["Cards"],
        "deprecated": true,
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `crd_` card id — validated for organization ownership, but the write itself is org-wide."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "description": "At least one field must carry a meaningful (non-undefined) value.",
                "properties": {
                  "card_payment_ledger_account_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed lac_ id, or null to clear."
                  },
                  "bill_payment_ledger_account_id": {
                    "type": ["string", "null"],
                    "description": "Prefixed lac_ id, or null to clear."
                  }
                }
              },
              "example": {
                "card_payment_ledger_account_id": "lac_64f1a2b3c4d5e6f708192a3b"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated mapping.",
            "headers": {
              "Deprecation": {
                "schema": {
                  "type": "string"
                }
              },
              "Sunset": {
                "schema": {
                  "type": "string"
                }
              },
              "Link": {
                "schema": {
                  "type": "string"
                },
                "description": "Points at /v1/cards/payment-accounts."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "card_id",
                        "card_payment_ledger_account_id",
                        "bill_payment_ledger_account_id",
                        "message"
                      ],
                      "properties": {
                        "card_id": {
                          "type": "string"
                        },
                        "card_payment_ledger_account_id": {
                          "type": ["string", "null"]
                        },
                        "bill_payment_ledger_account_id": {
                          "type": ["string", "null"]
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No valid fields to update, a ledger account is not a leaf account, or a malformed id."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The card does not exist in your organization, or a supplied ledger account does not exist in your organization."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Upstream Ramp error while writing the mapping."
          }
        }
      }
    },
    "/tags/delete": {
      "post": {
        "operationId": "bulkDeleteTags",
        "summary": "Bulk soft-delete tags (in-use tags block the whole batch)",
        "description": "Soft-deletes up to 100 tags in one call, all-or-nothing: **if ANY tag in the batch is currently in use (`usage_count > 0` on a journal-entry line, template, or elsewhere), NOTHING in the batch is deleted.** This is deliberately stricter than a per-tag partial-success contract — it exists to prevent deletion from ever leaving a dangling tag reference on a JE line or template. On the 409, `in_use_count` tells you how many of the submitted tags were blocking; remove the tag from its usages first (or drop it from the batch) and retry.\n\n`confirm_count` is required and must equal the number of DISTINCT ids in the request — this is the destructive-batch confirmation pattern used across v1 bulk-deletes, not a `dry_run` (an all-or-nothing delete has no partial blast radius to preview). Ids outside your organization, or already deleted, are silently excluded from the count and the operation. Sibling: `DELETE /v1/tags/{id}` removes one tag without the confirm_count ceremony.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids", "confirm_count"],
                "additionalProperties": false,
                "properties": {
                  "ids": {
                    "type": "array",
                    "maxItems": 100,
                    "description": "Prefixed `tag_` ids to delete (max 100).",
                    "items": {
                      "type": "string"
                    }
                  },
                  "confirm_count": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Must equal the number of DISTINCT ids in this request — a deliberate mismatch-detection guard, not a row-count estimate."
                  }
                }
              },
              "example": {
                "ids": ["tag_64f1a2b3c4d5e6f708192a3b"],
                "confirm_count": 1
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tags soft-deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["deleted_count", "deleted_ids"],
                      "properties": {
                        "deleted_count": {
                          "type": "integer"
                        },
                        "deleted_ids": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "confirm_count does not equal the number of distinct ids, or a schema violation."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "One or more tags are currently in use and cannot be deleted. Response includes in_use_count. Nothing in the batch was deleted."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/tags/apply": {
      "post": {
        "operationId": "applyTag",
        "summary": "Apply a tag across a template's lines or journal entries",
        "description": "Applies one tag to journal-entry lines selected by ledger-account type — either on the template's own lines (`apply_to: \"template\"`) or on every journal entry the template has already generated, plus their matching lines (`apply_to: \"journal\"`). Additive only: uses a set-union write, so re-applying an already-present tag is a safe no-op and existing tags on those lines are never removed. Not a bulk-tagging endpoint for arbitrary entries — line selection is always template-scoped and filtered by `line_types`.\n\n`line_types` values are the CAPITALIZED ledger-account-type enum (`Asset`, `Liability`, `Equity`, `Income`, `Expense`) — case-sensitive, and notably different casing from the lowercase vocabulary used elsewhere in the ledger-accounts API. At least one type is required. `template_ids` accepts up to 200 templates; ids outside your organization are silently skipped, and the whole call 404s only when NONE of the supplied templates are owned by your organization.\n\nDisambiguation: for a broader multi-record tag/untag on journal entries directly (not template-scoped), use `POST /v1/journal-entries/bulk-add-tags` instead.",
        "tags": ["Tags"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["tag_id", "apply_to", "template_ids", "line_types"],
                "additionalProperties": false,
                "properties": {
                  "tag_id": {
                    "type": "string",
                    "description": "Prefixed `tag_` id."
                  },
                  "apply_to": {
                    "type": "string",
                    "enum": ["template", "journal"],
                    "description": "`template` tags the template's own lines; `journal` tags the journal entries the template generated (and their lines)."
                  },
                  "template_ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 200,
                    "description": "Prefixed `tpl_` ids (max 200). Ids outside your organization are silently skipped.",
                    "items": {
                      "type": "string"
                    }
                  },
                  "line_types": {
                    "type": "array",
                    "minItems": 1,
                    "description": "Capitalized ledger-account types — case-sensitive.",
                    "items": {
                      "type": "string",
                      "enum": ["Asset", "Liability", "Equity", "Income", "Expense"]
                    }
                  }
                }
              },
              "example": {
                "tag_id": "tag_64f1a2b3c4d5e6f708192a3b",
                "apply_to": "journal",
                "template_ids": ["tpl_64f1a2b3c4d5e6f708192a3c"],
                "line_types": ["Expense"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tag was applied.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["tag_id", "apply_to", "updated_lines", "message"],
                      "properties": {
                        "tag_id": {
                          "type": "string"
                        },
                        "apply_to": {
                          "type": "string",
                          "enum": ["template", "journal"]
                        },
                        "updated_lines": {
                          "type": "integer"
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "tag_id is not owned by your organization, or none of the supplied template_ids belong to your organization."
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "502": {
            "description": "Upstream tag-apply service returned a malformed response."
          }
        }
      }
    },
    "/ledger-accounts/bulk-patch": {
      "post": {
        "operationId": "bulkPatchLedgerAccounts",
        "summary": "Bulk reparent ledger accounts or toggle clearing-account status",
        "description": "Bulk-patches `parent_account_id` and/or `is_clearing_account` across up to 200 ledger accounts in one call. **Per-account partial success** — unlike the tag/rule-set bulk-deletes elsewhere in v1, a bad id here does not fail the whole request: each account either succeeds or lands in `failed[]` with a reason (malformed id, not found, self-parent, or a circular parent chain), and the batch keeps going.\n\n`parent_account_id` is tri-state: omit the key to leave the parent untouched, send `null` to clear it, or send a `lac_` id to set it. At least one of `parent_account_id` or `is_clearing_account` must be present. Reparenting cascades `is_leaf` recomputation: the new parent becomes non-leaf, and any old parent that loses its last child becomes leaf again. For the single-account, strict one-at-a-time path use `PATCH /v1/ledger-accounts/{id}` instead.",
        "tags": ["Ledger Accounts"],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "additionalProperties": false,
                "description": "At least one of parent_account_id or is_clearing_account must be present.",
                "properties": {
                  "ids": {
                    "type": "array",
                    "maxItems": 200,
                    "description": "Prefixed `lac_` ids (max 200).",
                    "items": {
                      "type": "string"
                    }
                  },
                  "parent_account_id": {
                    "type": ["string", "null"],
                    "description": "Omit to leave unchanged; null clears the parent; a lac_ id sets it."
                  },
                  "is_clearing_account": {
                    "type": "boolean"
                  }
                }
              },
              "example": {
                "ids": ["lac_64f1a2b3c4d5e6f708192a3b"],
                "is_clearing_account": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success batch result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["message", "succeeded", "failed", "succeeded_count", "failed_count"],
                      "properties": {
                        "message": {
                          "type": "string"
                        },
                        "succeeded": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "failed": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string"
                              },
                              "reason": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "succeeded_count": {
                          "type": "integer"
                        },
                        "failed_count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Neither parent_account_id nor is_clearing_account supplied, more than 200 ids, or parent_account_id references an account outside your organization."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    },
    "/events": {
      "post": {
        "operationId": "publishEvent",
        "summary": "Publish a custom real-time event",
        "description": "Triggers a real-time event that SSE-connected clients (e.g. the in-app activity panel) can receive — for callers, such as the AI copilot, that own their own background work and want to surface progress without depending on the platform's job queue. `type` must be in the `custom.<scope>[.<sub>]` namespace (e.g. `custom.copilot.tool_complete`); the reserved `job.*` namespace is rejected so a caller can never spoof a platform job-progress event. `payload` is optional, must be a JSON object (not a scalar), and is capped at 32 KB serialized.\n\nPurely a fan-out publish with no persistence: there is nothing to read back, list, or undo. `published: false` in the response is not an error — it means the API is not running in a mode with a realtime transport configured (no cloud provider / no Redis publisher), so the event simply had nowhere to go. Rate-limited to 60 events per minute per organization, tracked in-memory per server instance.",
        "tags": ["Events"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["type"],
                "additionalProperties": false,
                "properties": {
                  "type": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 128,
                    "pattern": "^custom(\\.[a-z0-9_]+)+$",
                    "description": "Must be in the custom.<scope>[.<sub>] namespace, case-insensitive. The reserved job.* namespace is rejected."
                  },
                  "payload": {
                    "type": "object",
                    "description": "Arbitrary JSON object, capped at 32 KB serialized. Defaults to {} when omitted."
                  }
                }
              },
              "example": {
                "type": "custom.copilot.tool_complete",
                "payload": {
                  "tool": "classify_transactions"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The publish attempt completed (see published for whether a transport actually delivered it).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["published", "type"],
                      "properties": {
                        "published": {
                          "type": "boolean",
                          "description": "False means no realtime transport is configured in this environment — not an error."
                        },
                        "type": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "type is outside the custom.* namespace, payload is not a JSON object, payload exceeds 32 KB serialized, or an unknown field was sent."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "description": "Rate limit exceeded — 60 events/minute per organization. Retry shortly."
          }
        }
      }
    },
    "/jobs/{id}": {
      "patch": {
        "operationId": "updateJob",
        "summary": "Pause, resume, or reschedule a recurring job",
        "description": "Updates a recurring job configuration (`job_` id space — the cron-style scheduled job, distinct from the ad-hoc BullMQ background jobs under `/v1/background-jobs/{id}`). Exactly two fields are settable: `paused` (toggle on/off) and `cron_expression` (reschedule) — nothing else about the job's behavior changes here. At least one must be present.\n\nDoes not validate cron syntax beyond a length check; an unparseable expression is accepted here and will surface as a scheduling failure later. Returns 404 when the job does not exist or belongs to another organization; mutations require a user-owned key (`requireUser`) — read-only and ownerless keys receive 403.",
        "tags": ["Jobs"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `job_` recurring-job-configuration id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "additionalProperties": false,
                "description": "At least one of paused or cron_expression is required.",
                "properties": {
                  "paused": {
                    "type": "boolean"
                  },
                  "cron_expression": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 128
                  }
                }
              },
              "example": {
                "paused": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated job configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": ["id", "status", "job_type", "is_paused", "cron_expression"],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "status": {
                          "type": ["string", "null"]
                        },
                        "job_type": {
                          "type": ["string", "null"]
                        },
                        "is_paused": {
                          "type": "boolean"
                        },
                        "cron_expression": {
                          "type": ["string", "null"]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Neither paused nor cron_expression supplied, or an unknown field was sent."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      },
      "delete": {
        "operationId": "deleteJob",
        "summary": "Delete a recurring job configuration",
        "description": "Soft-deletes the recurring job configuration itself. **This is a permanent removal of the schedule, not a pause.** To temporarily stop a job while keeping it around to resume later, use `PATCH /v1/jobs/{id}` with `paused: true` instead; this endpoint has no undo. Deleting does not cancel any in-flight run of the job's underlying work — it only stops future scheduled invocations.\n\nOrg-scoped: unlike the legacy delete-by-id path (which had no organization filter), this endpoint's lookup is scoped to your organization, so it can never delete another org's job. Returns 404 when the job does not exist, was already deleted, or belongs to another organization. Mutations require a user-owned key.",
        "tags": ["Jobs"],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Prefixed `job_` recurring-job-configuration id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "204": {
            "description": "The job configuration was soft-deleted. No response body."
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "API key from Entendre dashboard. Prefixed `ek_live_` (production) or `ek_test_` (test)."
      },
      "OAuth2": {
        "type": "oauth2",
        "description": "OAuth 2.0 for third-party applications acting on behalf of Entendre users.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.entendre.finance/oauth/authorize",
            "tokenUrl": "https://api.entendre.finance/oauth/token",
            "scopes": {
              "apis.read": "Read-only access to all v1 resources.",
              "apis.all": "Full read and write access to all v1 resources."
            }
          }
        }
      }
    },
    "parameters": {
      "IdQuery": {
        "name": "id",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Exact prefixed ID for single-record lookup."
      },
      "LimitQuery": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100
        },
        "description": "Page size (1-100). Per-endpoint default — most endpoints default to 50 when omitted; some (cards) default to 25, some (lite / start-dates) return their HARD_CAP. Pass an explicit value when you need a specific page size."
      },
      "CursorQuery": {
        "name": "cursor",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Opaque pagination cursor from a previous response."
      },
      "PathId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Prefixed resource ID."
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "UUIDv4 idempotency key. Same key + same body = cached original response. Same key + different body = 409 IDEMPOTENCY_CONFLICT. Keys retained 24 hours. Validation failures do not consume the key."
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "description": "Maximum requests allowed per window.",
        "schema": {
          "type": "integer",
          "examples": [60]
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests remaining in the current window.",
        "schema": {
          "type": "integer",
          "examples": [42]
        }
      },
      "X-RateLimit-Reset": {
        "description": "Unix timestamp (seconds) when the current window resets.",
        "schema": {
          "type": "integer",
          "examples": [1710072000]
        }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying (only on 429 responses).",
        "schema": {
          "type": "integer",
          "examples": [30]
        }
      },
      "Request-Id": {
        "description": "Unique request identifier for debugging and support escalation. Include this value in any support ticket.",
        "schema": {
          "type": "string",
          "examples": ["req_7xKp2mN8vBqLsT"]
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "UNAUTHORIZED",
                "message": "API key is missing or invalid.",
                "display_message": "Authentication failed. Please check your credentials.",
                "suggested_action": "Include a valid API key in the X-API-Key header.",
                "documentation_url": "https://docs.entendre.finance/api-reference/authentication",
                "request_id": "req_7xKp2mN8vBqLsT"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Insufficient permissions.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "FORBIDDEN",
                "message": "You do not have access to the requested resource.",
                "display_message": "Access denied.",
                "suggested_action": "Verify the resource ID belongs to your organization.",
                "documentation_url": "https://docs.entendre.finance/api-reference/authentication#available-scopes",
                "request_id": "req_4mNpLqR9xKvWsY"
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "Request validation failed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "VALIDATION_ERROR",
                "message": "Request validation failed.",
                "display_message": "Some required fields are missing or invalid.",
                "fields": {
                  "accounting_date": "required"
                },
                "suggested_action": "Fix the fields listed in the `fields` object and resubmit.",
                "documentation_url": "https://docs.entendre.finance/api-reference/errors",
                "request_id": "req_2vBqMnK7xLpTs9"
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "RESOURCE_NOT_FOUND",
                "message": "Resource not found.",
                "display_message": "The requested item could not be found.",
                "suggested_action": "Verify the ID is correct and belongs to your organization.",
                "documentation_url": "https://docs.entendre.finance/api-reference/errors",
                "request_id": "req_5kDsQmN1vBpLwT"
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "Business integrity constraint violated.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "CONFLICT",
                "message": "Operation conflicts with existing state.",
                "display_message": "This action cannot be completed due to existing dependencies.",
                "suggested_action": "Review the message for the specific constraint violated.",
                "documentation_url": "https://docs.entendre.finance/api-reference/errors",
                "request_id": "req_6rHwMpN4xKvBqT"
              }
            }
          }
        }
      },
      "UnbalancedJournal": {
        "description": "Journal entry lines do not balance.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "UNBALANCED_JOURNAL",
                "message": "Sum of debits does not equal sum of credits.",
                "display_message": "Journal entry lines are out of balance.",
                "suggested_action": "Adjust line amounts so total debits equal total credits.",
                "documentation_url": "https://docs.entendre.finance/api-reference/journal-entries",
                "request_id": "req_9wCrNpL3mKvQxB"
              }
            }
          }
        }
      },
      "RateLimitExceeded": {
        "description": "Rate limit exceeded.",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          },
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "RATE_LIMIT_EXCEEDED",
                "message": "Rate limit exceeded. 60 requests/minute limit reached.",
                "display_message": "Too many requests. Please slow down and try again shortly.",
                "suggested_action": "Wait 30 seconds (see Retry-After header) then retry with exponential backoff.",
                "documentation_url": "https://docs.entendre.finance/api-reference/rate-limits",
                "request_id": "req_1kJxLpN5vMqBsR"
              }
            }
          }
        }
      },
      "SyncThrottled": {
        "description": "Sync already triggered within the 5-minute throttle window.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": {
                "code": "SYNC_THROTTLED",
                "message": "Sync already triggered within the last 5 minutes.",
                "display_message": "Sync already in progress. Please wait a few minutes.",
                "suggested_action": "Wait 5 minutes before triggering another sync.",
                "documentation_url": "https://docs.entendre.finance/api-reference/financial-accounts#trigger-sync",
                "request_id": "req_7mBnKpR3xLvTqW"
              }
            }
          }
        }
      }
    },
    "schemas": {
      "OperationalRule": {
        "type": "object",
        "required": ["rule_id", "domain", "title", "statement", "created_at"],
        "properties": {
          "rule_id": {
            "type": "string",
            "description": "Opaque rule id (e.g. `rule_ab12cd34`). Pass to `tools.operational_rules.delete_operational_rule`."
          },
          "domain": {
            "type": "string",
            "enum": ["classification", "cash_application"],
            "description": "Which copilot engine consults the rule: `classification` (transaction categorization) or `cash_application` (invoice/deposit matching)."
          },
          "title": {
            "type": "string",
            "description": "Short human-readable rule name."
          },
          "statement": {
            "type": "string",
            "description": "Human-readable summary of what the rule matches and where it routes."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "action": {
            "type": "string",
            "enum": ["route_to_account", "require_human_review"],
            "description": "cash_application only — what the rule does when its matcher fires."
          },
          "matcher": {
            "type": "string",
            "description": "cash_application only — the verbatim matcher phrase compared against invoice line text."
          },
          "match_operator": {
            "type": "string",
            "enum": ["exact", "contains"],
            "description": "cash_application only — how `matcher` is compared (`contains` fires on any occurrence; `exact` requires the normalized line label to equal it)."
          },
          "normalized_matcher": {
            "type": "string",
            "description": "cash_application only — engine-effective matcher (lowercased, punctuation stripped); matching is case/punctuation-insensitive against this value."
          },
          "ledger_account_id": {
            "type": "string",
            "description": "cash_application route_to_account only — target ledger account."
          },
          "ledger_account_name": {
            "type": "string"
          },
          "description_contains": {
            "type": "string",
            "description": "classification only — literal memo/payee substring matched by the engine."
          },
          "source_category": {
            "type": "string",
            "description": "classification only — source-platform category matched by the engine."
          },
          "merchant_name": {
            "type": "string",
            "description": "classification only — merchant name matched by the engine."
          },
          "blockchain_address": {
            "type": "string",
            "description": "classification only — from/to blockchain address matched by the engine."
          },
          "asset_type": {
            "type": "string",
            "description": "classification only — asset symbol matched by the engine."
          },
          "direction": {
            "type": "string",
            "enum": ["DEPOSIT", "WITHDRAWAL", "ANY"],
            "description": "classification only — transaction direction constraint."
          },
          "category_ledger_account_id": {
            "type": "string",
            "description": "classification only — prefixed target ledger account id."
          },
          "category_ledger_account_name": {
            "type": "string",
            "description": "classification only — target ledger account name."
          },
          "category_ledger_account_sequence": {
            "type": ["integer", "null"],
            "description": "classification only — target ledger account sequence."
          },
          "resolved_payee": {
            "type": "string",
            "description": "classification only — clean payee applied by the rule."
          },
          "default_tag_id": {
            "type": "string",
            "description": "classification only — prefixed tag applied by the rule."
          }
        }
      },
      "V1TableGroup": {
        "type": "object",
        "required": ["id", "object", "table_type", "name"],
        "properties": {
          "id": { "type": ["string", "null"], "description": "Prefixed `tbg_` id." },
          "object": { "type": "string", "enum": ["table_group"] },
          "organization_id": { "type": ["string", "null"], "description": "Prefixed `org_` id." },
          "table_type": {
            "type": ["string", "null"],
            "enum": ["asset", "source", null],
            "description": "Whether the group organizes asset rows or source rows."
          },
          "name": { "type": ["string", "null"] },
          "icon": { "type": ["string", "null"] },
          "emoji": { "type": ["string", "null"] },
          "reference_wallet_id": {
            "type": ["string", "null"],
            "description": "Prefixed `wal_` id of the reference wallet, when set."
          },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "V1TableGroupFeedItem": {
        "type": "object",
        "required": ["object", "source_type", "id"],
        "properties": {
          "object": { "type": "string", "enum": ["source"] },
          "source_type": {
            "type": "string",
            "enum": [
              "wallet",
              "raincard",
              "niural",
              "plaid_account",
              "manual_bank_account",
              "exchange_source",
              "ramp_card",
              "ramp_bank_account",
              "unknown"
            ]
          },
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed id of the underlying source (e.g. `wal_`, `fac_`)."
          },
          "name": { "type": ["string", "null"] },
          "legal_entity_id": { "type": ["string", "null"], "description": "Prefixed `le_` id." },
          "table_group_id": {
            "type": ["string", "null"],
            "description": "Prefixed `tbg_` id of the containing group, when grouped."
          }
        }
      },
      "V1TableGroupFeedFolder": {
        "description": "A table-group folder in the feed: the group's own fields plus its member sources nested in `sub_rows`.",
        "allOf": [
          { "$ref": "#/components/schemas/V1TableGroup" },
          {
            "type": "object",
            "properties": {
              "sub_rows": {
                "type": "array",
                "items": { "$ref": "#/components/schemas/V1TableGroupFeedItem" }
              }
            }
          }
        ]
      },
      "TransactionCategory": {
        "type": "string",
        "description": "Economic transaction type (the full closed set — do not invent values). Some values use spaces, not underscores.",
        "enum": [
          "DEPOSIT",
          "WITHDRAWAL",
          "SWAP",
          "NON_TAXABLE_CONVERSION",
          "BRIDGE",
          "INTERCOMPANY TRANSFER",
          "INTERNAL TRANSFER",
          "FEE",
          "MINTING",
          "STAKING_REWARD",
          "VALIDATOR_REWARD",
          "INVOICE",
          "BILL",
          "CLAIM REWARD",
          "BORROW",
          "REPAYMENT",
          "RESERVES CHANGE",
          "REALIZED_PNL",
          "INCOME",
          "EXPENSE",
          "REFUND",
          "CHARGEBACK",
          "NFT",
          "SPAM",
          "UNKNOWN"
        ]
      },
      "V1BillPaymentAllocation": {
        "type": "object",
        "required": ["bill_id", "amount"],
        "properties": {
          "bill_id": { "type": ["string", "null"], "description": "Prefixed `bil_` id of the allocated bill." },
          "amount": { "type": "string", "description": "Allocated amount, decimal string (HALF_EVEN, 2-place)." }
        }
      },
      "V1BillPayment": {
        "type": "object",
        "required": ["id", "total_amount", "applied_amount", "allocations"],
        "properties": {
          "id": { "type": ["string", "null"], "description": "Prefixed `bp_` id." },
          "organization_id": { "type": ["string", "null"], "description": "Prefixed `org_` id." },
          "paid_from_account_id": {
            "type": ["string", "null"],
            "description": "Prefixed `fac_` financial-account id, or null."
          },
          "transaction_id": { "type": ["string", "null"], "description": "Prefixed `txn_` id, or null." },
          "total_amount": { "type": "string", "description": "Total payment amount, decimal string." },
          "applied_amount": { "type": "string", "description": "Sum of allocation amounts (derived), decimal string." },
          "txn_date": { "type": ["string", "null"], "description": "Payment date, YYYY-MM-DD." },
          "currency": { "type": ["string", "null"], "description": "ISO currency code; defaults to USD." },
          "allocations": { "type": "array", "items": { "$ref": "#/components/schemas/V1BillPaymentAllocation" } },
          "notes": { "type": ["string", "null"] },
          "created_by": { "type": ["string", "null"], "description": "Prefixed `usr_` id of the creator, or null." },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "RampConnection": {
        "type": "object",
        "required": [
          "id",
          "organization_id",
          "is_connected",
          "has_credentials",
          "legal_entity_id",
          "card_payment_ledger_account_id",
          "bill_payment_ledger_account_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": { "type": ["string", "null"] },
          "organization_id": { "type": ["string", "null"] },
          "is_connected": { "type": "boolean" },
          "has_credentials": {
            "type": "boolean",
            "description": "Whether API client credentials are stored. The credentials themselves are never returned."
          },
          "legal_entity_id": { "type": ["string", "null"], "description": "Prefixed `le_` id the connection maps to." },
          "card_payment_ledger_account_id": { "type": ["string", "null"] },
          "bill_payment_ledger_account_id": { "type": ["string", "null"] },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "RampBankAccount": {
        "type": "object",
        "required": [
          "id",
          "organization_id",
          "legal_entity_id",
          "external_bank_account_id",
          "account_number_last_four",
          "financial_institution_name",
          "ledger_account_id",
          "bill_payment_ledger_account_id",
          "merged_into_bank_account_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": { "type": ["string", "null"] },
          "organization_id": { "type": ["string", "null"] },
          "legal_entity_id": { "type": ["string", "null"] },
          "external_bank_account_id": { "type": "string" },
          "account_number_last_four": { "type": "string" },
          "financial_institution_name": { "type": ["string", "null"] },
          "ledger_account_id": { "type": ["string", "null"] },
          "bill_payment_ledger_account_id": { "type": ["string", "null"] },
          "merged_into_bank_account_id": {
            "type": ["string", "null"],
            "description": "Set when this account was merged into a Plaid source; such rows are hidden unless `include_merged=true`."
          },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "RampSyncLog": {
        "type": "object",
        "required": [
          "id",
          "organization_id",
          "sync_type",
          "status",
          "start_date",
          "end_date",
          "counts",
          "error",
          "duration_ms",
          "task_id",
          "retry_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": { "type": ["string", "null"] },
          "organization_id": { "type": ["string", "null"] },
          "sync_type": { "type": "string" },
          "status": { "type": "string" },
          "start_date": { "type": ["string", "null"], "format": "date-time" },
          "end_date": { "type": ["string", "null"], "format": "date-time" },
          "counts": {
            "type": "object",
            "properties": {
              "transactions_added": { "type": "integer" },
              "transactions_modified": { "type": "integer" },
              "bills_added": { "type": "integer" },
              "bills_modified": { "type": "integer" },
              "statements_added": { "type": "integer" },
              "statements_modified": { "type": "integer" },
              "reimbursements_added": { "type": "integer" },
              "reimbursements_modified": { "type": "integer" }
            }
          },
          "error": { "type": ["string", "null"] },
          "duration_ms": { "type": ["number", "null"] },
          "task_id": { "type": ["string", "null"] },
          "retry_count": { "type": "integer" },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "CardPaymentAccountConfig": {
        "type": "object",
        "required": ["card_payment_ledger_account_id", "bill_payment_ledger_account_id"],
        "properties": {
          "card_payment_ledger_account_id": {
            "type": ["string", "null"],
            "description": "Prefixed `lac_` id used to book card payments."
          },
          "bill_payment_ledger_account_id": {
            "type": ["string", "null"],
            "description": "Prefixed `lac_` id used to book bill payments."
          },
          "message": { "type": "string", "description": "Present on update responses only." },
          "card_id": {
            "type": "string",
            "description": "Echoed only by the deprecated per-card payment-account aliases (GET/PATCH /cards/payment-accounts/{id}); absent from the org-level singleton."
          }
        }
      },
      "Source": {
        "type": "object",
        "required": [
          "id",
          "source_type",
          "organization_id",
          "legal_entity_id",
          "name",
          "status",
          "created_at",
          "updated_at",
          "detail"
        ],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed source id; the prefix varies by `source_type` (`exs_`/`sts_`/`nis_`/`rfs_`/`ccs_`/`rnc_`/`crd_`/`rbk_`/`mba_`)."
          },
          "source_type": {
            "type": "string",
            "enum": [
              "exchange",
              "staking",
              "niural",
              "request_finance",
              "credit_coop",
              "raincard",
              "ramp_card",
              "ramp_bank_account",
              "manual_bank"
            ]
          },
          "organization_id": { "type": ["string", "null"] },
          "legal_entity_id": { "type": ["string", "null"] },
          "name": {
            "type": ["string", "null"],
            "description": "Human label, resolved per type (card nickname, bank institution + last-four, etc.)."
          },
          "status": {
            "type": ["string", "null"],
            "description": "Lifecycle status. Only exchange sources carry one (lowercased); `null` for every other type by design (there is no synthesized status). For a ramp_card's lifecycle read `detail.state`; other types expose their own state under `detail`."
          },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "updated_at": { "type": ["string", "null"], "format": "date-time" },
          "detail": {
            "type": "object",
            "additionalProperties": true,
            "description": "Per-type detail bag whose keys vary by `source_type` (e.g. exchange → `exchange_source_type`, `has_credentials`, `credential_status`, `tags`, `ledger_account_id`, `parent_source_id`; staking → `address`, `chain`, `last_block_queried`; manual_bank → `account_name`, `bank_name`, `account_number_last_four`). Credentials are NEVER emitted — only `has_credentials` + `credential_status`."
          }
        }
      },
      "Accrual": {
        "type": "object",
        "description": "A vendor accrual row. `vendor_id` is `vnd_<id>`, `journal_entry_id` is `je_<id>`.",
        "properties": {
          "id": {
            "type": ["string", "null"]
          },
          "vendor_id": {
            "type": ["string", "null"]
          },
          "vendor_name": {
            "type": ["string", "null"]
          },
          "amount": {
            "type": ["string", "null"],
            "description": "Decimal string."
          },
          "period": {
            "type": ["string", "null"],
            "description": "YYYY-MM."
          },
          "status": {
            "type": ["string", "null"]
          },
          "journal_entry_id": {
            "type": ["string", "null"]
          },
          "journal_sequence_number": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"]
          }
        }
      },
      "AccrualCreateRequest": {
        "type": "object",
        "required": ["accruals"],
        "properties": {
          "accruals": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "object",
              "required": ["vendor_id", "amount", "period"],
              "properties": {
                "vendor_id": {
                  "type": "string",
                  "description": "`vnd_<id>` or bare id."
                },
                "vendor_name": {
                  "type": "string"
                },
                "amount": {
                  "type": "number",
                  "description": "Positive accrual amount."
                },
                "period": {
                  "type": "string",
                  "pattern": "^\\d{4}-(0[1-9]|1[0-2])$",
                  "description": "YYYY-MM."
                },
                "reason": {
                  "type": "string"
                },
                "confidence": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 100
                },
                "expected_bill_id": {
                  "type": "string"
                },
                "notes": {
                  "type": "string"
                },
                "legal_entity_id": {
                  "type": "string",
                  "description": "`le_<id>` or bare id."
                },
                "legal_entity_name": {
                  "type": "string"
                }
              }
            }
          },
          "auto_reverse": {
            "type": "boolean",
            "description": "Marks the accrual for auto-reversal (echoed back as `reversalScheduled`). This does NOT schedule a per-accrual reversal job; an org-wide monthly cron reverses every posted accrual for the prior period regardless of this flag."
          }
        }
      },
      "AccrualCreateResult": {
        "type": "object",
        "description": "Per-entry create result. `created`/`failed` partition the requested accruals.",
        "properties": {
          "accruals_created": {
            "type": "integer"
          },
          "accruals_failed": {
            "type": "integer"
          },
          "total_amount": {
            "type": "string"
          },
          "reversal_date": {
            "type": ["string", "null"]
          },
          "created": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "accrual_id": {
                  "type": "string"
                },
                "journal_entry_id": {
                  "type": ["string", "null"]
                },
                "journal_sequence_number": {
                  "type": ["string", "null"]
                },
                "vendor_id": {
                  "type": ["string", "null"]
                },
                "vendor_name": {
                  "type": "string"
                },
                "amount": {
                  "type": "number"
                },
                "period": {
                  "type": "string"
                },
                "reversal_date": {
                  "type": ["string", "null"]
                },
                "confidence": {
                  "type": ["integer", "null"]
                }
              }
            }
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "vendor_id": {
                  "type": ["string", "null"]
                },
                "vendor_name": {
                  "type": "string"
                },
                "amount": {
                  "type": "number"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "duplicate",
                    "vendor_account_not_mapped",
                    "multiple_legal_entities",
                    "accounting_period_closed",
                    "unknown"
                  ]
                },
                "message": {
                  "type": "string"
                },
                "details": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      },
      "AccrualReverseRequest": {
        "type": "object",
        "description": "Provide exactly one of `accrual_id` or `journal_sequence_number`.",
        "properties": {
          "accrual_id": {
            "type": "string"
          },
          "journal_sequence_number": {
            "type": "string"
          },
          "reason": {
            "type": "string"
          }
        }
      },
      "AccrualReverseResult": {
        "type": "object",
        "properties": {
          "accrual_id": {
            "type": ["string", "null"]
          },
          "status": {
            "type": "string"
          },
          "journal_sequence_number": {
            "type": ["string", "null"]
          },
          "reason": {
            "type": "string"
          },
          "reversed": {
            "type": "boolean"
          }
        }
      },
      "MissingBillsResult": {
        "type": "object",
        "properties": {
          "period": {
            "type": "string"
          },
          "mode": {
            "type": "string"
          },
          "total_missing": {
            "type": "integer"
          },
          "total_amount": {
            "type": "string"
          },
          "high_confidence_count": {
            "type": "integer"
          },
          "high_confidence_amount": {
            "type": "string"
          },
          "medium_confidence_count": {
            "type": "integer"
          },
          "medium_confidence_amount": {
            "type": "string"
          },
          "missing_bills": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "expected_bill_id": {
                  "type": ["string", "null"]
                },
                "vendor_id": {
                  "type": ["string", "null"]
                },
                "vendor_name": {
                  "type": "string"
                },
                "expected_date": {
                  "type": "string"
                },
                "expected_amount": {
                  "type": "number"
                },
                "confidence": {
                  "type": "integer"
                },
                "confidence_reason": {
                  "type": ["string", "null"]
                },
                "status": {
                  "type": "string"
                },
                "source": {
                  "type": "string"
                },
                "last_bill_date": {
                  "type": ["string", "null"]
                },
                "billing_frequency": {
                  "type": ["string", "null"],
                  "enum": ["weekly", "monthly", "quarterly", "annual", "irregular", null]
                },
                "bill_count": {
                  "type": ["integer", "null"]
                },
                "recommendation": {
                  "type": ["string", "null"]
                }
              }
            }
          }
        }
      },
      "VendorInsightsResult": {
        "type": "object",
        "description": "Either an org-wide rollup (`mode=org_rollup`) or a single-vendor detail. Fields vary by mode.",
        "additionalProperties": true,
        "properties": {
          "mode": {
            "type": "string"
          },
          "total_vendors": {
            "type": "integer"
          },
          "active_vendors": {
            "type": "integer"
          },
          "vendors_with_patterns": {
            "type": "integer"
          },
          "vendor_id": {
            "type": ["string", "null"]
          },
          "vendor_name": {
            "type": ["string", "null"]
          }
        }
      },
      "AccrualMonthEndResult": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "period": {
            "type": ["string", "null"],
            "description": "Single month covered, or null for a range request."
          },
          "start_period": {
            "type": ["string", "null"],
            "description": "Inclusive range start (null for a single-period request)."
          },
          "end_period": {
            "type": ["string", "null"],
            "description": "Inclusive range end (null for a single-period request)."
          },
          "total_accruals": {
            "type": "integer"
          },
          "total_amount": {
            "type": "string"
          },
          "accuracy_percent": {
            "type": "number"
          },
          "reversal_rate_percent": {
            "type": "number"
          },
          "average_confidence": {
            "type": "number"
          },
          "vendor_breakdown": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "recommendations": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "top_recommendation": {
            "type": "string"
          }
        }
      },
      "PrepaidSchedule": {
        "type": "object",
        "description": "A prepaid schedule. `bill_id` is `bil_<uuid>`, `vendor_id` is `vnd_<uuid>`. `schedule_id` is a UUID for persisted rows or a `virtual_<billId>` sentinel for detected ones (NOT prefixed).",
        "properties": {
          "schedule_id": {
            "type": ["string", "null"]
          },
          "bill_id": {
            "type": ["string", "null"]
          },
          "vendor_id": {
            "type": ["string", "null"]
          },
          "vendor_name": {
            "type": "string"
          },
          "total_amount": {
            "type": "string"
          },
          "monthly_amount": {
            "type": "string"
          },
          "remaining_balance": {
            "type": "string"
          },
          "service_period_start": {
            "type": ["string", "null"]
          },
          "service_period_end": {
            "type": ["string", "null"]
          },
          "status": {
            "type": ["string", "null"]
          },
          "is_virtual": {
            "type": "boolean"
          },
          "detection_reason": {
            "type": ["string", "null"]
          }
        }
      },
      "PrepaidScheduleList": {
        "type": "object",
        "properties": {
          "total_schedules": {
            "type": "integer"
          },
          "active_schedules": {
            "type": "integer"
          },
          "completed_schedules": {
            "type": "integer"
          },
          "total_remaining": {
            "type": "string"
          },
          "next_amortization": {
            "type": "string"
          },
          "summary": {
            "type": "string"
          },
          "schedules": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PrepaidSchedule"
            }
          }
        }
      },
      "ProcessPrepaidRequest": {
        "type": "object",
        "properties": {
          "bill_id": {
            "type": "string",
            "description": "`bil_<uuid>` or bare id."
          },
          "vendor_id": {
            "type": "string",
            "description": "`vnd_<uuid>` or bare id."
          },
          "process_amortizations": {
            "type": "boolean",
            "description": "Default true."
          },
          "detect_unprocessed": {
            "type": "boolean",
            "description": "Default true."
          }
        }
      },
      "ProcessPrepaidResult": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "summary": {
            "type": "object",
            "properties": {
              "detections_found": {
                "type": "integer"
              },
              "initial_entries_created": {
                "type": "integer"
              },
              "amortizations_processed": {
                "type": "integer"
              },
              "errors_encountered": {
                "type": "integer"
              }
            }
          },
          "journal_entry_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "`je_<id>` of created journal entries."
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "QBOBill": {
        "type": "object",
        "description": "A live QuickBooks bill. `vendor_id` is the internal vault vendor (`vnd_<id>`, nullable); `qbo_bill_id`/`external_vendor_id` are QuickBooks-side ids (raw).",
        "properties": {
          "qbo_bill_id": {
            "type": "string"
          },
          "sync_token": {
            "type": "string"
          },
          "vendor_id": {
            "type": ["string", "null"]
          },
          "external_vendor_id": {
            "type": "string"
          },
          "vendor_name": {
            "type": "string"
          },
          "bill_date": {
            "type": "string"
          },
          "due_date": {
            "type": ["string", "null"]
          },
          "bill_number": {
            "type": ["string", "null"]
          },
          "total_amount": {
            "type": "string"
          },
          "balance": {
            "type": ["string", "null"]
          },
          "currency": {
            "type": ["string", "null"]
          },
          "private_note": {
            "type": ["string", "null"]
          },
          "line_count": {
            "type": "integer"
          },
          "source": {
            "type": "string"
          },
          "created_time": {
            "type": ["string", "null"]
          },
          "last_updated_time": {
            "type": ["string", "null"]
          }
        }
      },
      "QBOBillList": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "total_bills": {
            "type": "integer"
          },
          "total_matching_bills": {
            "type": "integer"
          },
          "total_amount": {
            "type": "string"
          },
          "bills": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/QBOBill"
            }
          }
        }
      },
      "QBOBillsCheckResult": {
        "type": "object",
        "properties": {
          "period": {
            "type": "string"
          },
          "vendors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "vendor_id": {
                  "type": ["string", "null"]
                },
                "external_vendor_id": {
                  "type": "string"
                },
                "vendor_name": {
                  "type": "string"
                },
                "bill_count": {
                  "type": "integer"
                },
                "total_amount": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "VendorPattern": {
        "type": "object",
        "description": "A vendor billing pattern derived from QBO history. `vendor_id` is internal (`vnd_<id>`, nullable); `external_vendor_id` is QuickBooks-side.",
        "properties": {
          "vendor_id": {
            "type": ["string", "null"]
          },
          "external_vendor_id": {
            "type": "string"
          },
          "vendor_name": {
            "type": "string"
          },
          "bill_count": {
            "type": "integer"
          },
          "total_amount": {
            "type": "string"
          },
          "average_amount": {
            "type": "string"
          },
          "amount_variance": {
            "type": "string"
          },
          "billing_frequency": {
            "type": ["string", "null"],
            "enum": ["weekly", "monthly", "quarterly", "annual", "irregular", null]
          },
          "expected_bill_day": {
            "type": ["integer", "null"]
          },
          "confidence_score": {
            "type": "number"
          },
          "last_bill_date": {
            "type": ["string", "null"]
          },
          "first_bill_date": {
            "type": ["string", "null"]
          },
          "bill_dates": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "BuildVendorScheduleRequest": {
        "type": "object",
        "properties": {
          "start_date": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "ISO date; omit to read all available QBO history."
          }
        }
      },
      "BuildVendorScheduleResult": {
        "type": "object",
        "properties": {
          "vendors_analyzed": {
            "type": "integer"
          },
          "vendors_created": {
            "type": "integer"
          },
          "vendors_classified_as_ap": {
            "type": "integer"
          },
          "patterns_persisted": {
            "type": "integer"
          },
          "total_bills": {
            "type": "integer"
          },
          "patterns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VendorPattern"
            }
          }
        }
      },
      "VaultDocument": {
        "type": "object",
        "description": "A Vault document (Postgres-backed). `id` is `doc_<uuid>`.",
        "properties": {
          "id": {
            "type": "string",
            "example": "doc_b1d4f6e0"
          },
          "organization_id": {
            "type": "string",
            "example": "org_123"
          },
          "document_type": {
            "type": "string",
            "enum": ["bill", "receipt", "statement", "other"]
          },
          "document_source": {
            "type": "string"
          },
          "file_directory": {
            "type": ["string", "null"]
          },
          "file_size": {
            "type": "integer"
          },
          "filename": {
            "type": "string"
          },
          "original_filename": {
            "type": ["string", "null"]
          },
          "file_extension": {
            "type": "string"
          },
          "summary": {
            "type": ["string", "null"]
          },
          "hash_sum": {
            "type": ["string", "null"]
          },
          "confidence_level": {
            "type": ["number", "null"]
          },
          "language": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"]
          },
          "deleted_at": {
            "type": ["string", "null"]
          },
          "signed_url": {
            "type": "string",
            "description": "Present only on the detail GET."
          },
          "signed_url_expires_at": {
            "type": "string",
            "description": "Present only on the detail GET."
          }
        }
      },
      "VaultBill": {
        "type": "object",
        "description": "A Vault bill. `id` is `bil_<uuid>`; amounts are 2-place decimal strings.",
        "properties": {
          "id": {
            "type": "string",
            "example": "bil_abc123"
          },
          "organization_id": {
            "type": ["string", "null"]
          },
          "document_id": {
            "type": ["string", "null"]
          },
          "vendor_id": {
            "type": ["string", "null"]
          },
          "bill_number": {
            "type": ["string", "null"]
          },
          "bill_date": {
            "type": ["string", "null"]
          },
          "due_date": {
            "type": ["string", "null"]
          },
          "billing_period_start": {
            "type": ["string", "null"]
          },
          "billing_period_end": {
            "type": ["string", "null"]
          },
          "currency": {
            "type": ["string", "null"]
          },
          "total_amount": {
            "type": ["string", "null"]
          },
          "tax_amount": {
            "type": ["string", "null"]
          },
          "tax_rate": {
            "type": ["string", "null"]
          },
          "tax_type": {
            "type": ["string", "null"]
          },
          "line_items": {
            "description": "Raw line-item JSON.",
            "nullable": true
          },
          "payment_instructions": {
            "description": "Raw payment-instruction JSON.",
            "nullable": true
          },
          "notes": {
            "type": ["string", "null"]
          },
          "external_bill_id": {
            "type": ["string", "null"]
          },
          "external_bill_source": {
            "type": ["string", "null"]
          },
          "is_accrued": {
            "type": "boolean"
          },
          "gl_reference": {
            "type": ["string", "null"]
          },
          "gl_sync_status": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"]
          }
        }
      },
      "VaultReceipt": {
        "type": "object",
        "description": "A Vault receipt. `id` is `rct_<uuid>`.",
        "properties": {
          "id": {
            "type": "string",
            "example": "rct_abc123"
          },
          "organization_id": {
            "type": ["string", "null"]
          },
          "document_id": {
            "type": ["string", "null"]
          },
          "vendor_id": {
            "type": ["string", "null"]
          },
          "receipt_number": {
            "type": ["string", "null"]
          },
          "invoice_number": {
            "type": ["string", "null"]
          },
          "receipt_date": {
            "type": ["string", "null"]
          },
          "currency": {
            "type": ["string", "null"]
          },
          "total_amount": {
            "type": ["string", "null"]
          },
          "subtotal_amount": {
            "type": ["string", "null"]
          },
          "tax_amount": {
            "type": ["string", "null"]
          },
          "tax_rate": {
            "type": ["string", "null"]
          },
          "tax_type": {
            "type": ["string", "null"]
          },
          "line_items": {
            "description": "Raw line-item JSON.",
            "nullable": true
          },
          "payment_method": {
            "type": ["string", "null"]
          },
          "notes": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"]
          }
        }
      },
      "VaultVendor": {
        "type": "object",
        "description": "A Vault vendor. `id` is `vnd_<uuid>`. List rows add `files_count` + `total_bills_amount`.",
        "properties": {
          "id": {
            "type": "string",
            "example": "vnd_abc123"
          },
          "organization_id": {
            "type": ["string", "null"]
          },
          "name": {
            "type": "string"
          },
          "website_url": {
            "type": ["string", "null"]
          },
          "is_ramp_managed": {
            "type": "boolean"
          },
          "is_ap_vendor": {
            "type": "boolean"
          },
          "default_gl_account": {
            "type": ["string", "null"]
          },
          "payment_terms": {
            "type": ["string", "null"]
          },
          "external_vendor_id": {
            "type": ["string", "null"]
          },
          "gl_type": {
            "type": ["string", "null"]
          },
          "realm_id": {
            "type": ["string", "null"]
          },
          "default_expense_account_id": {
            "type": ["string", "null"]
          },
          "last_gl_sync": {
            "type": ["string", "null"]
          },
          "gl_sync_status": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"]
          },
          "files_count": {
            "type": "integer",
            "description": "List rows only."
          },
          "total_bills_amount": {
            "type": ["string", "null"]
          }
        }
      },
      "VaultBankStatement": {
        "type": "object",
        "description": "A Vault bank statement. `id` is `bnk_<uuid>`.",
        "properties": {
          "id": {
            "type": "string",
            "example": "bnk_abc123"
          },
          "document_id": {
            "type": ["string", "null"]
          },
          "organization_id": {
            "type": ["string", "null"]
          },
          "bank_name": {
            "type": ["string", "null"]
          },
          "account_number": {
            "type": ["string", "null"]
          },
          "account_number_last_four": {
            "type": ["string", "null"]
          },
          "account_name": {
            "type": ["string", "null"]
          },
          "linked_account_id": {
            "type": ["string", "null"]
          },
          "statement_date": {
            "type": ["string", "null"]
          },
          "period_start": {
            "type": ["string", "null"]
          },
          "period_end": {
            "type": ["string", "null"]
          },
          "opening_balance": {
            "type": ["string", "null"]
          },
          "closing_balance": {
            "type": ["string", "null"]
          },
          "currency": {
            "type": "string"
          },
          "account_category": {
            "type": "string"
          },
          "reconciliation_status": {
            "type": "string"
          },
          "transactions_count": {
            "type": ["integer", "null"]
          },
          "reconciled_at": {
            "type": ["string", "null"]
          },
          "reconciled_by": {
            "type": ["string", "null"]
          },
          "notes": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"]
          }
        }
      },
      "VaultDocumentEnvelope": {
        "type": "object",
        "description": "A document plus its joined siblings, populated per the `?expand=` query.",
        "required": ["document"],
        "properties": {
          "document": {
            "$ref": "#/components/schemas/VaultDocument"
          },
          "bill": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VaultBill"
              },
              {
                "type": "null"
              }
            ]
          },
          "receipt": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VaultReceipt"
              },
              {
                "type": "null"
              }
            ]
          },
          "vendor": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VaultVendor"
              },
              {
                "type": "null"
              }
            ]
          },
          "statement": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VaultBankStatement"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "VaultBillEnvelope": {
        "type": "object",
        "description": "A bill with its joined document + vendor.",
        "required": ["bill"],
        "properties": {
          "bill": {
            "$ref": "#/components/schemas/VaultBill"
          },
          "document": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VaultDocument"
              },
              {
                "type": "null"
              }
            ]
          },
          "vendor": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VaultVendor"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "V1Activity": {
        "type": "object",
        "description": "Background-job activity row (BullMQ progress, downloads, long-running computations).",
        "required": ["id", "job_id", "job_name", "status"],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "`act_…`"
          },
          "organization_id": {
            "type": ["string", "null"],
            "description": "`org_…`"
          },
          "job_id": {
            "type": "string",
            "description": "Polymorphic — wallet/source/org id depending on job kind."
          },
          "job_name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "Lowercase: `pending`, `in_progress`, `completed`, `failed`."
          },
          "progress": {
            "type": ["number", "null"],
            "minimum": 0,
            "maximum": 1
          },
          "message": {
            "type": ["string", "null"]
          },
          "error_message": {
            "type": ["string", "null"]
          },
          "display_name": {
            "type": ["string", "null"]
          },
          "download_url": {
            "type": ["string", "null"],
            "description": "Signed URL (for downloads). Time-limited."
          },
          "result": {
            "description": "Job-specific result payload."
          },
          "completed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "initiated_by": {
            "type": ["string", "null"],
            "description": "`usr_…` of the user who started the job."
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "V1Notification": {
        "type": "object",
        "description": "In-app notification (badge counter, activity-panel surface).",
        "required": ["id", "type", "is_read"],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "`nfn_…`"
          },
          "organization_id": {
            "type": ["string", "null"],
            "description": "`org_…`"
          },
          "type": {
            "type": "string",
            "enum": ["CUSTOM", "JOB", "TASK", "COPILOT"]
          },
          "sub_type": {
            "type": ["string", "null"],
            "description": "Free-form category string."
          },
          "message": {
            "type": ["string", "null"]
          },
          "is_read": {
            "type": "boolean"
          },
          "related_job_configuration_id": {
            "type": ["string", "null"],
            "description": "`job_…`"
          },
          "related_job_history_id": {
            "type": ["string", "null"],
            "description": "Raw ObjectId — no v1 prefix yet."
          },
          "related_previous_job_run_id": {
            "type": ["string", "null"],
            "description": "Raw ObjectId — no v1 prefix yet."
          },
          "related_task_id": {
            "type": ["string", "null"],
            "description": "Raw ObjectId — no v1 prefix yet."
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "V1CashApplicationAllocation": {
        "type": "object",
        "required": ["target_type", "amount"],
        "properties": {
          "target_type": {
            "type": "string",
            "enum": ["invoice"],
            "description": "Polymorphic — currently always `invoice`."
          },
          "target_id": {
            "type": ["string", "null"],
            "description": "`inv_…` — `null` for unresolved (proposed) allocations."
          },
          "invoice_number": {
            "type": ["string", "null"]
          },
          "amount": {
            "type": "string",
            "description": "Decimal string."
          },
          "customer_name": {
            "type": ["string", "null"]
          }
        }
      },
      "V1CashApplication": {
        "type": "object",
        "description": "Resource-shaped cash-application. Envelope + allocations[] with orthogonal money fields (`total_amount` / `applied_amount` / `unapplied_amount`) and a 4-state status (`draft` / `applied` / `partially_applied` / `voided`) plus a separate `stripe_status` axis.",
        "required": [
          "id",
          "organization_id",
          "status",
          "stripe_status",
          "total_amount",
          "applied_amount",
          "unapplied_amount",
          "allocations"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`ca_…` (canonical). Legacy callers may see `cam_…` from the strangler-fig shim."
          },
          "organization_id": {
            "type": "string",
            "description": "`org_…`"
          },
          "legal_entity_id": {
            "type": ["string", "null"],
            "description": "`le_…`"
          },
          "deposit_transaction_id": {
            "type": ["string", "null"],
            "description": "`txn_…` — the deposit being applied."
          },
          "status": {
            "type": "string",
            "enum": ["draft", "applied", "partially_applied", "voided"]
          },
          "stripe_status": {
            "type": ["string", "null"],
            "enum": ["pending", "synced", "failed", null]
          },
          "match_type": {
            "type": ["string", "null"],
            "enum": ["exact", "multi_invoice", "tolerance", "manual", null]
          },
          "total_amount": {
            "type": "string"
          },
          "applied_amount": {
            "type": "string",
            "description": "Computed from `allocations[].amount` sum."
          },
          "unapplied_amount": {
            "type": "string"
          },
          "deposit_memo": {
            "type": ["string", "null"]
          },
          "txn_date": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "allocations": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/V1CashApplicationAllocation"
            }
          },
          "applied_by": {
            "type": ["string", "null"]
          },
          "journal_entry_id": {
            "type": ["string", "null"],
            "description": "`je_…` — set on apply."
          },
          "stripe_marked_paid_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "confirmed_by": {
            "type": ["string", "null"],
            "description": "`usr_…`"
          },
          "confirmed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "error_message": {
            "type": ["string", "null"]
          }
        }
      },
      "BulkResult": {
        "type": "object",
        "description": "Partial-success response for bulk operations. Every requested ID appears in exactly one of `succeeded` or `failed`.",
        "required": ["succeeded", "failed"],
        "properties": {
          "succeeded": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs that were processed successfully."
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["id", "reason"],
              "properties": {
                "id": {
                  "type": "string",
                  "description": "The ID that failed."
                },
                "reason": {
                  "type": "string",
                  "description": "Human-readable explanation of why the operation failed for this ID."
                }
              }
            },
            "description": "IDs that could not be processed, each with a reason."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message", "request_id"],
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code."
              },
              "message": {
                "type": "string",
                "description": "Technical description for developers."
              },
              "display_message": {
                "type": "string",
                "description": "Safe to show to end users."
              },
              "fields": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Present only on VALIDATION_ERROR. Maps failing field paths to reason strings."
              },
              "suggested_action": {
                "type": "string",
                "description": "Actionable next step. AI agents can use this to self-correct."
              },
              "documentation_url": {
                "type": "string",
                "format": "uri"
              },
              "request_id": {
                "type": "string",
                "description": "Unique request ID for support escalation."
              }
            }
          }
        }
      },
      "Address": {
        "type": "object",
        "required": ["line1", "city", "country"],
        "properties": {
          "line1": {
            "type": "string"
          },
          "line2": {
            "type": ["string", "null"]
          },
          "city": {
            "type": "string"
          },
          "state": {
            "type": ["string", "null"],
            "description": "State/province. Optional for countries without states."
          },
          "country": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2 country code (e.g., `US`, `GB`)."
          },
          "zipcode": {
            "type": "string"
          }
        }
      },
      "Organization": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed ID (`org_`).",
            "examples": ["org_123abc"]
          },
          "name": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "web_address": {
            "type": ["string", "null"]
          },
          "timezone": {
            "type": "string"
          },
          "time_format": {
            "type": "string",
            "enum": ["hr12", "hr24"]
          },
          "onboarding_status": {
            "type": ["string", "null"]
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "name", "created_at"]
      },
      "LegalEntity": {
        "type": "object",
        "required": ["id", "name", "entity_type", "status", "base_currency", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["le_456def"]
          },
          "name": {
            "type": "string"
          },
          "entity_type": {
            "type": "string",
            "enum": [
              "C-CORP",
              "S-CORP",
              "LLC",
              "LP",
              "LLP",
              "GmbH",
              "Ltd",
              "PLC",
              "Foundation",
              "Trust",
              "Non-Profit",
              "Sole Proprietorship",
              "DAO",
              "Other"
            ]
          },
          "status": {
            "type": "string",
            "enum": ["active", "archived"]
          },
          "base_currency": {
            "type": "string"
          },
          "address": {
            "$ref": "#/components/schemas/Address"
          },
          "address_string": {
            "type": ["string", "null"],
            "description": "Free-form, single-line address. Stored independently of the structured `address` — the structured `address` is authoritative when both are set."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "FinancialAccount": {
        "type": "object",
        "required": ["id", "organization_id", "type", "name", "status", "legal_entity_id", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["fac_0987654321"]
          },
          "organization_id": {
            "type": "string",
            "description": "Prefixed organization ID.",
            "examples": ["org_abc123"]
          },
          "type": {
            "type": "string",
            "enum": ["wallet", "exchange", "bank_account"]
          },
          "provider": {
            "type": "string",
            "enum": [
              "wallet",
              "fireblocks",
              "plaid",
              "kraken",
              "binance",
              "coinbase",
              "coinbase_prime",
              "coinbase_exchange",
              "coinbase_international",
              "deribit",
              "bitmex",
              "bybit",
              "kucoin",
              "gate",
              "mexc",
              "gemini",
              "woo",
              "okx",
              "bitfinex",
              "circle",
              "canton"
            ],
            "description": "Provider name. See `POST /financial-accounts` for valid values per account type."
          },
          "name": {
            "type": "string"
          },
          "legal_entity_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["healthy", "disconnected", "error"]
          },
          "last_synced_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "details": {
            "type": "object",
            "description": "Provider-specific details. Shape varies by `type`.",
            "properties": {
              "chain": {
                "type": "string"
              },
              "address": {
                "type": "string"
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Card": {
        "type": "object",
        "required": ["id", "provider", "name", "status", "legal_entity_id", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["crd_0987654321"]
          },
          "provider": {
            "type": "string",
            "enum": ["ramp", "rain"]
          },
          "name": {
            "type": "string"
          },
          "legal_entity_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["healthy", "disconnected", "error"]
          },
          "last_synced_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CardSyncJob": {
        "type": "object",
        "description": "Ramp sync job/log row. Same payload as `RampSyncLog` (both `GET /cards/sync-jobs` and the deprecated `GET /cards/sync-history` serialize via `transformRampSyncLog`); every field is always present, some nullable.",
        "required": [
          "id",
          "organization_id",
          "sync_type",
          "status",
          "start_date",
          "end_date",
          "counts",
          "error",
          "duration_ms",
          "task_id",
          "retry_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed sync job id (`rms_…`).",
            "examples": ["rms_0987654321"]
          },
          "organization_id": {
            "type": ["string", "null"]
          },
          "sync_type": {
            "type": "string",
            "description": "Lowercased: manual / scheduled / webhook / initial / historical."
          },
          "status": {
            "type": "string",
            "description": "Lowercased: pending / in_progress / completed / failed / cancelled."
          },
          "start_date": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "end_date": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "counts": {
            "type": "object",
            "properties": {
              "transactions_added": {
                "type": "integer"
              },
              "transactions_modified": {
                "type": "integer"
              },
              "bills_added": {
                "type": "integer"
              },
              "bills_modified": {
                "type": "integer"
              },
              "statements_added": {
                "type": "integer"
              },
              "statements_modified": {
                "type": "integer"
              },
              "reimbursements_added": {
                "type": "integer"
              },
              "reimbursements_modified": {
                "type": "integer"
              }
            }
          },
          "error": {
            "type": ["string", "null"]
          },
          "duration_ms": {
            "type": ["number", "null"]
          },
          "task_id": {
            "type": ["string", "null"]
          },
          "retry_count": {
            "type": "integer"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "CardWebhook": {
        "type": "object",
        "required": ["ramp_webhook_id", "url", "events", "status", "created_at"],
        "description": "An inbound Ramp webhook configuration. The signing secret, business_id, and updated_at are intentionally not exposed.",
        "properties": {
          "ramp_webhook_id": {
            "type": ["string", "null"],
            "description": "The raw Ramp webhook id (not an Entendre prefixed id)."
          },
          "url": {
            "type": ["string", "null"]
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "Bill": {
        "type": "object",
        "required": [
          "id",
          "provider",
          "vendor_name",
          "amount",
          "currency",
          "status",
          "due_date",
          "legal_entity_id",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["bil_987xyz"]
          },
          "provider": {
            "type": "string",
            "enum": ["ramp", "rain", "niural"]
          },
          "vendor_name": {
            "type": "string"
          },
          "amount": {
            "type": "string",
            "description": "Decimal string."
          },
          "currency": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["open", "paid", "canceled"]
          },
          "due_date": {
            "type": "string",
            "format": "date-time"
          },
          "legal_entity_id": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ValueFiat": {
        "type": "object",
        "description": "Fiat value breakdown. Derived from token amounts: `gross = quantity × unit_price`, `fee = token_fee × unit_price`, `net = gross − fee`. All amounts are decimal strings, never floats.",
        "required": ["gross", "net", "currency"],
        "properties": {
          "gross": {
            "type": "string",
            "description": "Total fiat value before fees (`quantity × unit price`). Used as the line amount for journal entries."
          },
          "fee": {
            "type": "string",
            "description": "Fee in fiat terms (`token fee × unit price`). `\"0.00\"` if no fee. When non-zero, the fee is typically posted as a separate FEE-classified transaction."
          },
          "net": {
            "type": "string",
            "description": "Fiat value after fees (`gross − fee`)."
          },
          "currency": {
            "type": "string",
            "description": "ISO 4217 currency code (e.g., `USD`, `EUR`)."
          }
        }
      },
      "Transaction": {
        "type": "object",
        "required": ["id", "account_id", "organization_id", "legal_entity_id", "source_class", "external", "internal"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["txn_abc123"]
          },
          "account_id": {
            "type": "string"
          },
          "organization_id": {
            "type": "string"
          },
          "legal_entity_id": {
            "type": "string"
          },
          "source_class": {
            "type": "string",
            "enum": ["crypto", "fiat", "card", "exchange", "other"]
          },
          "provider": {
            "type": "string",
            "enum": [
              "wallet",
              "fireblocks",
              "plaid",
              "ramp",
              "rain",
              "kraken",
              "binance",
              "coinbase",
              "coinbase_prime",
              "coinbase_exchange",
              "coinbase_international",
              "deribit",
              "bitmex",
              "bybit",
              "kucoin",
              "gate",
              "mexc",
              "gemini",
              "woo",
              "okx",
              "bitfinex",
              "circle",
              "canton",
              "maple",
              "niural",
              "request_finance",
              "finch_payroll"
            ],
            "description": "The provider/source that originated this transaction."
          },
          "vendor": {
            "type": ["string", "null"],
            "description": "The counterparty this transaction paid or was paid by, already resolved: `card.merchant_name` → `bank.counterparty_name` → `internal.memo` (only when `external.description` is a generic source placeholder such as \"Bank transaction\") → `external.description`. **Group by THIS field for per-vendor spend.** Grouping by `bank.counterparty_name` alone silently drops every row without Plaid merchant enrichment — the majority of bank rows on a typical org. Grouping by `external.description` alone collapses those same rows into one meaningless \"Bank transaction\" bucket, which is why the memo leg exists: for ACH and bank-feed rows the counterparty identity lives in the memo. The value may still be a raw bank narration (\"AUTOMATIC PAYMENT - THANK\", a `CD DEPOSIT` string, an ACH payroll reference) when that is all the feed carries; those are counterparties but not trade vendors, so judge the label before ranking it."
          },
          "provider_category": {
            "type": ["string", "null"],
            "description": "Raw category metadata assigned by Ramp or Plaid. It is not accounting treatment."
          },
          "source_category": {
            "type": ["string", "null"],
            "deprecated": true,
            "description": "Deprecated alias for `provider_category`."
          },
          "external": {
            "type": "object",
            "properties": {
              "timestamp": {
                "type": "string",
                "format": "date-time"
              },
              "description": {
                "type": "string"
              },
              "direction": {
                "type": "string",
                "enum": ["credit", "debit"]
              },
              "quantity": {
                "type": ["object", "null"],
                "description": "Token quantity breakdown (crypto only). `null` for fiat/card transactions.",
                "properties": {
                  "gross": {
                    "type": "string",
                    "description": "Gross token quantity before fees. For a 1.5 ETH transfer with 0.003 ETH gas, `gross` is `\"1.5\"`."
                  },
                  "fee": {
                    "type": "string",
                    "description": "Token fee deducted (e.g., gas in native token). Same unit as `currency`."
                  },
                  "net": {
                    "type": "string",
                    "description": "Net token quantity after fees (`gross - fee`)."
                  }
                }
              },
              "currency": {
                "type": "string",
                "description": "Token symbol (crypto) or ISO 4217 (fiat)."
              },
              "value_fiat": {
                "$ref": "#/components/schemas/ValueFiat"
              }
            }
          },
          "crypto": {
            "type": ["object", "null"],
            "description": "Present when `source_class` is `crypto`. `null` otherwise.",
            "properties": {
              "chain": {
                "type": "string"
              },
              "asset_type": {
                "type": "string"
              },
              "from_address": {
                "type": "string"
              },
              "to_address": {
                "type": "string"
              },
              "hash": {
                "type": "string"
              }
            }
          },
          "bank": {
            "type": ["object", "null"],
            "description": "Present when `source_class` is `fiat`. `null` otherwise.",
            "properties": {
              "bank_name": {
                "type": "string"
              },
              "account_last4": {
                "type": "string"
              },
              "counterparty_name": {
                "type": "string"
              }
            }
          },
          "card": {
            "type": ["object", "null"],
            "description": "Present when `source_class` is `card`. `null` otherwise.",
            "properties": {
              "merchant_name": {
                "type": "string"
              },
              "merchant_category": {
                "type": "string"
              },
              "card_last4": {
                "type": "string"
              },
              "employee_name": {
                "type": "string"
              }
            }
          },
          "internal": {
            "type": "object",
            "properties": {
              "transaction_type": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/TransactionCategory"
                  }
                ],
                "description": "Economic transaction type. It is not accounting treatment. Use the linked journal entry's debit/credit lines and ledger accounts for accounting treatment."
              },
              "status": {
                "type": "string",
                "enum": ["pending", "posted", "spam"]
              },
              "category": {
                "type": "string",
                "deprecated": true,
                "description": "Deprecated legacy field. Use `transaction_type`. On unclassified historical rows it can contain the storage-level kind (`transaction`, `transfer`, or `fee`) rather than an economic transaction type."
              },
              "memo": {
                "type": ["string", "null"]
              },
              "journal_entry_id": {
                "type": ["string", "null"],
                "description": "Prefixed ID of the associated journal entry (e.g., `je_abc123`). `null` if no journal entry has been posted."
              }
            }
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "ISO 8601 timestamp when the transaction was ingested."
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "ISO 8601 timestamp of the last update."
          }
        }
      },
      "Document": {
        "type": "object",
        "required": ["id", "file_name", "status", "legal_entity_id", "uploaded_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["doc_abc123"]
          },
          "file_name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": ["bill", "invoice", "receipt", "statement", "other"]
          },
          "status": {
            "type": "string",
            "enum": ["processing", "processed", "failed"]
          },
          "legal_entity_id": {
            "type": "string"
          },
          "memo": {
            "type": ["string", "null"]
          },
          "linked_transaction_id": {
            "type": ["string", "null"]
          },
          "linked_bill_id": {
            "type": ["string", "null"]
          },
          "uploaded_at": {
            "type": "string",
            "format": "date-time"
          },
          "processed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "OcrResult": {
        "type": "object",
        "properties": {
          "document_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["processing", "processed", "failed"]
          },
          "confidence": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Score below 0.7 may require manual review."
          },
          "extracted": {
            "type": "object",
            "properties": {
              "vendor_name": {
                "type": "string"
              },
              "vendor_address": {
                "type": "string"
              },
              "invoice_number": {
                "type": "string"
              },
              "invoice_date": {
                "type": "string",
                "format": "date-time"
              },
              "due_date": {
                "type": "string",
                "format": "date-time"
              },
              "total_amount": {
                "type": "string"
              },
              "currency": {
                "type": "string"
              },
              "line_items": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "description": {
                      "type": "string"
                    },
                    "quantity": {
                      "type": "number"
                    },
                    "unit_price": {
                      "type": "string"
                    },
                    "amount": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "LedgerAccount": {
        "type": "object",
        "required": ["id", "name", "type", "normal_balance", "is_postable", "is_archived", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["lac_789abc"]
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": ["Asset", "Liability", "Equity", "Income", "Expense"]
          },
          "normal_balance": {
            "type": "string",
            "enum": ["debit", "credit"],
            "description": "Computed from type. Asset/Expense = debit, Liability/Equity/Income = credit."
          },
          "sequence_number": {
            "type": ["integer", "null"],
            "description": "Chart-of-accounts ordering code (e.g. Assets `1xxxx`, Liabilities `2xxxx`). `null` when the stored value is not a valid non-negative integer — some legacy/auto-created accounts carry junk values that are not meaningful ordinals."
          },
          "parent_account_id": {
            "type": ["string", "null"]
          },
          "is_postable": {
            "type": "boolean",
            "description": "`true` = postable child account that can receive journal entry lines. `false` = parent grouping account."
          },
          "is_clearing_account": {
            "type": "boolean",
            "description": "`true` = clearing account used for internal transfers and payables. `false` = standard ledger account."
          },
          "is_archived": {
            "type": "boolean",
            "description": "`true` when the account has been archived via `tools.ledger_accounts.archive_ledger_account`. Archived rows stay referenceable by historical journal entries but are hidden from default `list_ledger_accounts` views — pass `?include_archived=true` to see them. Defaults to `false` (and is `false` on rows created before the archive feature shipped)."
          },
          "archived_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "Timestamp the account was archived, or `null` when not archived. Cleared when `unarchive_ledger_account` runs."
          },
          "asset_type": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "JournalEntryLine": {
        "type": "object",
        "required": ["id", "ledger_account_id", "legal_entity_id", "credit_or_debit", "amount", "currency"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["jel_def456"]
          },
          "ledger_account_id": {
            "type": "string"
          },
          "legal_entity_id": {
            "type": "string",
            "description": "Legal entity this line belongs to. Typically inherited from the parent journal entry or derived from the transaction's source, but can differ in multi-entity scenarios."
          },
          "credit_or_debit": {
            "type": "string",
            "enum": ["DEBIT", "CREDIT"]
          },
          "amount": {
            "type": "string",
            "description": "Decimal string."
          },
          "currency": {
            "type": "string"
          },
          "memo": {
            "type": ["string", "null"]
          },
          "tag_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "JournalEntry": {
        "type": "object",
        "required": ["id", "status", "legal_entity_id", "lines", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["je_abc123"]
          },
          "organization_id": {
            "type": ["string", "null"],
            "description": "Prefixed organization ID (`org_` prefix).",
            "examples": ["org_abc123"]
          },
          "sequence_number": {
            "type": "string",
            "description": "Human-readable sequence (e.g., `JE-42`)."
          },
          "status": {
            "type": "string",
            "enum": ["draft", "posted", "reversed", "unposted", "in_progress", "error"]
          },
          "originated_by": {
            "type": "string"
          },
          "accounting_date": {
            "type": "string",
            "format": "date-time"
          },
          "posted_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "`null` for DRAFT entries."
          },
          "memo": {
            "type": ["string", "null"]
          },
          "source_type": {
            "type": ["string", "null"],
            "enum": [
              "TRANSACTION",
              "ASSETS",
              "REVALUATION",
              "REVERSE_REVALUATION",
              "NIURAL_INVOICE",
              "ACCRUAL",
              "MANUAL",
              "BILL_EXPENSE",
              "QUICKBOOKS",
              "STRIPE_INVOICE",
              "STRIPE_PAYMENT",
              "STRIPE_FEE",
              "STRIPE_DISPUTE",
              "CASH_APPLICATION",
              null
            ],
            "description": "What produced this entry (raw stored enum). `null` if unset."
          },
          "sync_date": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When this entry was last synced to its external GL; `null` if never synced. Resolved from the GL sync record (or rollup timestamp) on list responses; `null` on single-entry mutation responses."
          },
          "last_synced_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "Durable sync audit: when this entry was last pushed to the external GL. Unlike `sync_date`, this survives unsync — it is never cleared, only overwritten by the next sync. `null` if never synced (or not yet backfilled)."
          },
          "last_unsynced_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "Durable sync audit: when this entry was last removed (unsynced) from the external GL. Never cleared. `last_unsynced_at > last_synced_at` means the entry is currently out of the GL."
          },
          "legal_entity_id": {
            "type": "string"
          },
          "transaction_id": {
            "type": ["string", "null"]
          },
          "transaction_sequence_number": {
            "type": ["string", "null"],
            "description": "Sequence number (e.g. `OT-1234`) of the source transaction, when populated. `null` on responses that don't expand the transaction."
          },
          "template_id": {
            "type": ["string", "null"],
            "description": "Template used to generate this entry, if applicable (`tpl_` prefix). `null` for manual or classification-based entries."
          },
          "accounting_period_id": {
            "type": ["string", "null"],
            "description": "Accounting period this entry falls in (`ap_` prefix). `null` if not assigned to a period."
          },
          "classification": {
            "type": ["string", "null"],
            "description": "Legacy transaction-type alias used to derive the lines."
          },
          "tag_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "reversal_chain": {
            "type": "object",
            "properties": {
              "previous_entry_id": {
                "type": ["string", "null"]
              },
              "next_entry_id": {
                "type": ["string", "null"]
              }
            }
          },
          "period_auto_reassigned": {
            "type": "boolean",
            "description": "True when this entry's accounting_date fell in a CLOSED accounting period and it was auto-filed under the current open period instead. Treat the entry as misfiled and reconcile (reopen the correct period and re-date, or reverse and re-post)."
          },
          "intended_accounting_date": {
            "type": ["string", "null"],
            "description": "When period_auto_reassigned is true, the entry's own accounting date (whose true period is closed); null otherwise."
          },
          "legal_entity_auto_assigned": {
            "type": "boolean",
            "description": "True when this entry had no explicit or transaction-supplied legal entity and was auto-assigned to the organization's single ACTIVE legal entity. Surfaces an otherwise silent decision — the assigned entity is `legal_entity_id`. In a multi-entity org, verify the entity is the intended one."
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JournalEntryLine"
            }
          },
          "is_sync": {
            "type": "boolean",
            "description": "Whether this entry has been synced to an external GL (QuickBooks, Xero, NetSuite, DualEntry, Campfire)."
          },
          "latest_gl_sync_attempt": {
            "type": ["object", "null"],
            "description": "Most recent GL sync attempt for this entry; `null` if a sync has never been attempted.",
            "properties": {
              "id": {
                "type": ["string", "null"],
                "description": "Prefixed (`jesa_`) id of the underlying sync attempt; `null` for attempts predating id tracking. Poll this to distinguish a fresh attempt from a stale prior one.",
                "examples": ["jesa_abc123"]
              },
              "gl_type": {
                "type": ["string", "null"],
                "enum": ["QUICKBOOKS", "XERO", "NETSUITE", "DUALENTRY", "CAMPFIRE", null],
                "description": "Target GL the sync attempt was made against."
              },
              "external_type": {
                "type": ["string", "null"],
                "description": "Provider-specific transaction type used (e.g. QBO Purchase, Deposit, BillPayment)."
              },
              "external_id": {
                "type": ["string", "null"],
                "description": "Posted external entry id in the GL (mirrors the sync record); `null` on failed/pending attempts."
              },
              "error_type": {
                "type": ["string", "null"],
                "enum": [
                  "AUTHENTICATION",
                  "MAPPING_MISSING",
                  "VALIDATION",
                  "UNKNOWN",
                  "DUPLICATE_DETECTED",
                  "DOC_NUMBER_COLLISION",
                  null
                ],
                "description": "Category of the failure, if the latest attempt errored; `null` when the attempt did not error."
              },
              "error_details": {
                "type": ["string", "null"],
                "description": "Human-readable detail for the failure, if any."
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Template": {
        "type": "object",
        "required": ["id", "organization_id", "name", "status", "legal_entity_id", "lines", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["tpl_789xyz"]
          },
          "organization_id": {
            "type": "string",
            "description": "Prefixed organization ID.",
            "examples": ["org_abc123"]
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["draft", "active", "archived"]
          },
          "memo": {
            "type": ["string", "null"]
          },
          "legal_entity_id": {
            "type": "string"
          },
          "currency": {
            "type": "string"
          },
          "entries_generated": {
            "type": "integer"
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "ledger_account_id": {
                  "type": "string"
                },
                "credit_or_debit": {
                  "type": "string",
                  "enum": ["DEBIT", "CREDIT"]
                },
                "amount_type": {
                  "type": "string",
                  "enum": ["NET_AMOUNT", "GROSS_AMOUNT", "FEE", "GAIN_OR_LOSS"],
                  "description": "Determines which part of the transaction's fiat value to use for this line."
                },
                "amount": {
                  "type": "string"
                },
                "allocation": {
                  "type": "number"
                },
                "memo": {
                  "type": ["string", "null"]
                }
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Last update timestamp.",
            "example": "2025-04-01T12:00:00.000Z"
          },
          "dynamic_direction": {
            "type": "boolean",
            "description": "Whether the template direction is determined dynamically from the transaction.",
            "example": false
          }
        }
      },
      "Balance": {
        "type": "object",
        "required": [
          "id",
          "ledger_account_id",
          "legal_entity_id",
          "accounting_period_id",
          "currency",
          "opening",
          "closing",
          "total_debits",
          "total_credits"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["bal_123def"]
          },
          "ledger_account_id": {
            "type": "string"
          },
          "legal_entity_id": {
            "type": "string"
          },
          "accounting_period_id": {
            "type": "string"
          },
          "period_start_date": {
            "type": "string",
            "format": "date-time"
          },
          "currency": {
            "type": "string"
          },
          "opening": {
            "type": "string",
            "description": "Opening balance (decimal string)."
          },
          "closing": {
            "type": "string",
            "description": "Closing balance."
          },
          "total_debits": {
            "type": "string",
            "description": "Sum of debit postings in this period."
          },
          "total_credits": {
            "type": "string",
            "description": "Sum of credit postings in this period."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AccountingPeriod": {
        "type": "object",
        "required": ["id", "organization_id", "name", "start_date", "end_date", "status"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["ap_123def"]
          },
          "organization_id": {
            "type": "string",
            "description": "Prefixed organization ID.",
            "examples": ["org_abc123"]
          },
          "name": {
            "type": "string"
          },
          "start_date": {
            "type": "string",
            "format": "date-time"
          },
          "end_date": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "enum": ["open", "soft_closed", "closed"]
          },
          "legal_entity_id": {
            "type": "string",
            "description": "Legal entity this period belongs to.",
            "example": "le_507f1f77bcf86cd799439011"
          },
          "closed_on_date": {
            "oneOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "description": "When the period was closed."
          },
          "closed_by": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "User who closed the period."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Creation timestamp.",
            "example": "2025-01-01T00:00:00.000Z"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Last update timestamp.",
            "example": "2025-01-01T00:00:00.000Z"
          }
        }
      },
      "Tag": {
        "type": "object",
        "required": ["id", "organization_id", "key", "value", "status", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["tag_456ghi"]
          },
          "organization_id": {
            "type": "string",
            "description": "Prefixed organization ID.",
            "examples": ["org_abc123"]
          },
          "key": {
            "type": "string",
            "description": "Free-form tag key. Common keys: `Customer`, `Supplier`, `Cost Center`, `Class`, `Staff`, `Product`, `Bank Account`."
          },
          "value": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["active", "archived"]
          },
          "usage_count": {
            "type": "integer"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Job": {
        "type": "object",
        "required": ["id", "status", "progress"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["job_987xyz"]
          },
          "status": {
            "type": "string",
            "enum": ["waiting", "active", "completed", "failed"]
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Completion percentage. Meaningful when status is `active`."
          },
          "result": {
            "type": ["object", "null"],
            "description": "`null` until job completes. On failure, contains `error` string."
          },
          "message": {
            "type": ["string", "null"],
            "description": "Persisted status/failure reason for the job (e.g. the error message on a failed job). Null when there is none."
          }
        }
      },
      "CostBasisSettings": {
        "type": "object",
        "required": ["methodology", "accounting_based"],
        "properties": {
          "methodology": {
            "type": "string",
            "enum": ["fifo", "lifo", "hifo", "acb", "wacb"],
            "description": "Cost basis disposal method. `fifo` = First In First Out, `lifo` = Last In First Out, `hifo` = Highest In First Out, `acb` = Adjusted Cost Base, `wacb` = Weighted Average Cost Base. Input is case-insensitive; values are returned lowercase."
          },
          "accounting_based": {
            "type": "string",
            "enum": ["wallet", "universal"],
            "description": "Lot tracking scope. `wallet` = separate lot queues per wallet (IRS required for US taxpayers from 2025). `universal` = single queue across all wallets. Input is case-insensitive; values are returned lowercase."
          }
        }
      },
      "RealizedGainsCurrencyBlock": {
        "type": "object",
        "required": [
          "currency",
          "total_proceeds",
          "total_cost_basis",
          "total_gain",
          "short_term_gain",
          "long_term_gain",
          "disposal_count",
          "sold_asset_count"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "description": "ISO 4217 fiat currency these totals are denominated in (from the lots' own records)."
          },
          "total_proceeds": {
            "type": "string",
            "description": "Sum of proceeds from this currency's disposals (decimal string)."
          },
          "total_cost_basis": {
            "type": "string",
            "description": "Sum of cost basis for this currency's disposals (decimal string)."
          },
          "total_gain": {
            "type": "string",
            "description": "Net gain or loss in this currency. Negative values indicate a net loss (decimal string)."
          },
          "short_term_gain": {
            "type": "string",
            "description": "Gain/loss from assets held 1 year or less (decimal string)."
          },
          "long_term_gain": {
            "type": "string",
            "description": "Gain/loss from assets held more than 1 year (365.25-day boundary) (decimal string)."
          },
          "disposal_count": {
            "type": "integer",
            "description": "Disposal events in this currency."
          },
          "sold_asset_count": {
            "type": "integer",
            "description": "Sold asset lots in this currency."
          }
        }
      },
      "TransactionInsightDataQuality": {
        "type": "object",
        "description": "Disclosure of what the transaction-sourced insight figures EXCLUDED (present on every `/reports/financial-insights/*` response when `source=transactions`; absent only for `source=gl`). On a disclosure timeout or error the block is STILL present with `status: 'unavailable'` and zeroed counts — treat that as 'exclusions unquantified', not 'nothing excluded'. The figures cover only non-spam transactions in the classification set the endpoint computes from — the full P&L set for cash-flow/profit/burn-rate/runway, the expense set for expenses/spending, the income set for revenue — and this block quantifies the dropped buckets against that SAME set, so an empty-looking figure is never mistaken for complete coverage.",
        "required": [
          "status",
          "window_transaction_count",
          "excluded_spam_count",
          "excluded_spam_by_currency",
          "excluded_non_pl_classification_count",
          "excluded_non_pl_classification_by_currency",
          "no_posted_journal_entry_count",
          "warnings"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": ["ok", "unavailable"],
            "description": "`ok` = the exclusion buckets were computed. `unavailable` = the disclosure aggregation timed out or errored, so the excluded amounts are UNQUANTIFIED for this response (counts are 0 and a [WARNING] says so). Lets a failed disclosure be told apart at runtime from a genuine all-zero one."
          },
          "window_transaction_count": {
            "type": "integer",
            "description": "All non-deleted transactions in the window/scope — the denominator for the excluded counts."
          },
          "excluded_spam_count": {
            "type": "integer",
            "description": "Spam-flagged transactions excluded from the figures."
          },
          "excluded_spam_by_currency": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TransactionInsightExclusionByCurrency"
            },
            "description": "Spam exclusions grouped by transaction currency. Each amount is a decimal string with 2dp. Never add amounts across currency blocks."
          },
          "excluded_non_pl_classification_count": {
            "type": "integer",
            "description": "Non-spam transactions excluded because their classification is outside the set THIS figure is computed from. Transfers, swaps, bridges, deposits/withdrawals and unknown rows are never included; for a one-sided figure (expenses, spending, revenue) the other side of the P&L is excluded and counted here too. Field name kept stable (\"non_pl\") for wire compatibility."
          },
          "excluded_non_pl_classification_by_currency": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TransactionInsightExclusionByCurrency"
            },
            "description": "Non-P&L exclusions grouped by transaction currency. Use the per-currency amounts, not only the count, to judge whether the exclusion is material. Never add amounts across currency blocks."
          },
          "no_posted_journal_entry_count": {
            "type": "integer",
            "description": "Transactions in the window with `hasAccounting: false` — LITERALLY the predicate the canonical `has_posted_journal_entry=false` transactions filter builds, so this count always equals what that filter returns for the same window (cross-checking it against `GET /v1/transactions?has_posted_journal_entry=false` is valid). WINDOW-level: spans the whole scope (including the spam / excluded-classification rows), so it is not figure-relative like the excluded_* amounts. Caveats, shared with the filter deliberately: a row linked only to a DRAFT/ERROR entry is treated as accounted and NOT counted; a row missing the `hasAccounting` field entirely is also NOT counted. Context that the figures are cash values, not GL-reconciled."
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "[WARNING]-prefixed prose for each non-zero excluded bucket. Surface these whenever material."
          }
        }
      },
      "TransactionInsightExclusionByCurrency": {
        "type": "object",
        "required": ["currency", "transaction_count", "amount"],
        "properties": {
          "currency": {
            "type": "string",
            "description": "Currency code from the excluded transaction rows. `UNKNOWN` means the source row has no currency."
          },
          "transaction_count": {
            "type": "integer",
            "description": "Excluded transactions in this currency block."
          },
          "amount": {
            "type": "string",
            "description": "Decimal128-summed gross value for this currency block, formatted with 2dp."
          }
        }
      },
      "RealizedGainsSummary": {
        "type": "object",
        "description": "Server-side totals over the same rows the paginated report returns, so the summary equals the sum a caller would get by walking every page. Money is summed in Decimal128 and serialized as decimal strings (no float round-trip), so a high-value or high-scale disposal is not silently rounded. The figure is still the `methodology` per-unit approximation (`sale_price × quantity_sold − cost_basis × quantity_sold`), NOT a methodology-aware (FIFO/LIFO/HIFO/WAC) accounting pass — the approximation is in the per-unit formula, not the arithmetic. `by_currency` always carries one block per lot currency in scope (empty array = no sold lots matched). The flat convenience totals (`currency`, `total_*`, `*_term_gain`) are present ONLY when every lot shares a single currency; on a multi-currency scope they are omitted — report each `by_currency` block separately and NEVER add amounts across currencies.",
        "required": ["methodology", "by_currency", "disposal_count", "sold_asset_count"],
        "properties": {
          "methodology": {
            "type": "string",
            "description": "Computation method. Currently always `per_unit_cost_basis_approximation` — the documented `sale_price * quantity_sold - cost_basis * quantity_sold` per-unit derivation, NOT a methodology-aware (FIFO/LIFO/HIFO/WAC) accounting pass."
          },
          "by_currency": {
            "type": "array",
            "description": "One totals block per lot currency (sorted by currency code). Currency comes from each lot's own record — the legal entity's currency its sale prices / cost bases were priced in. Blocks are never blended: to present a multi-currency scope, report each block in its own currency.",
            "items": {
              "$ref": "#/components/schemas/RealizedGainsCurrencyBlock"
            }
          },
          "disposal_count": {
            "type": "integer",
            "description": "Total number of disposal events in the period, across all currencies (counts are currency-independent)."
          },
          "sold_asset_count": {
            "type": "integer",
            "description": "Number of sold asset lots aggregated across all currencies (the rows the paginated report would return)."
          },
          "currency": {
            "type": "string",
            "description": "ISO 4217 fiat currency of the flat totals. Present ONLY when the whole scope is single-currency."
          },
          "total_proceeds": {
            "type": "string",
            "description": "Sum of proceeds from all disposals (decimal string). Present ONLY when single-currency."
          },
          "total_cost_basis": {
            "type": "string",
            "description": "Sum of cost basis for all disposals (decimal string). Present ONLY when single-currency."
          },
          "total_gain": {
            "type": "string",
            "description": "Net gain or loss. Negative values indicate a net loss (decimal string). Present ONLY when single-currency."
          },
          "short_term_gain": {
            "type": "string",
            "description": "Gain/loss from assets held 1 year or less (decimal string). Present ONLY when single-currency."
          },
          "long_term_gain": {
            "type": "string",
            "description": "Gain/loss from assets held more than 1 year (365.25-day boundary, matching the dashboard's split) (decimal string). Present ONLY when single-currency."
          }
        }
      },
      "RealizedGainLine": {
        "type": "object",
        "required": [
          "id",
          "transaction_id",
          "asset_type",
          "quantity",
          "date_acquired",
          "date_disposed",
          "proceeds",
          "cost_basis",
          "gain",
          "holding_period",
          "currency"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "transaction_id": {
            "type": "string",
            "description": "The disposal transaction."
          },
          "asset_type": {
            "type": "string",
            "description": "Token symbol (e.g. `ETH`, `BTC`)."
          },
          "quantity": {
            "type": "string",
            "description": "Token quantity disposed (decimal string)."
          },
          "date_acquired": {
            "type": "string",
            "format": "date-time",
            "description": "When the lot was originally acquired."
          },
          "date_disposed": {
            "type": "string",
            "format": "date-time",
            "description": "When the disposal occurred."
          },
          "proceeds": {
            "type": "string",
            "description": "Fiat value received at disposal (decimal string)."
          },
          "cost_basis": {
            "type": "string",
            "description": "Original fiat cost of the lot (decimal string)."
          },
          "gain": {
            "type": "string",
            "description": "Realized gain or loss (`proceeds - costBasis`). Negative = loss (decimal string)."
          },
          "holding_period": {
            "type": "string",
            "enum": ["short", "long"],
            "description": "`short` = held 1 year or less, `long` = held more than 1 year."
          },
          "currency": {
            "type": "string",
            "description": "ISO 4217 fiat currency for proceeds/costBasis/gain."
          }
        }
      },
      "Agent": {
        "type": "object",
        "required": ["id", "name", "agent_type", "is_active", "organization_id", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["agt_abc123"]
          },
          "name": {
            "type": "string"
          },
          "agent_type": {
            "type": "string",
            "enum": [
              "treasury",
              "transaction_monitor",
              "expense_specialist",
              "expense",
              "gl_reconciliation",
              "gl_reconciliation_specialist",
              "gl_integration",
              "reconciliation",
              "accounting_specialist",
              "balance_sheet",
              "vendor_accruals",
              "wallet_balance_monitor",
              "custom"
            ],
            "description": "Agent type. Always \"custom\" when created via API."
          },
          "is_active": {
            "type": "boolean",
            "description": "true = running on schedule. false = paused."
          },
          "description": {
            "type": ["string", "null"]
          },
          "cron_expressions": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Raw 5-field cron expressions. Show the user the human-readable `schedule` instead of these raw strings."
          },
          "schedule": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Human-readable schedule, one entry per `cron_expressions` item (same order) — e.g. `\"At 09:00 AM\"`. Times are wall-clock in the organization's timezone (the clock the scheduler fires on; NOT UTC and not the viewer's local zone) — present them verbatim and do not shift the hour. Present this to the user rather than the raw cron. Empty when the agent has no schedule."
          },
          "triggers": {
            "type": "array",
            "description": "Every trigger attached to this agent — schedule AND event/webhook — the authoritative answer to \"what fires this agent?\". Additive companion to `cron_expressions`/`schedule`, which only ever describe the schedule trigger. What fires the agent is its ACTIVE triggers (`is_active: true`): an EVENT-driven agent (an active `plaid_webhook`/`ramp_webhook`/`quickbooks_webhook`/`xero_webhook`/`email_webhook` trigger) has an empty `cron_expressions`/`schedule` but a populated `triggers`, so do NOT report such an agent as unscheduled — read `triggers` and describe its event wiring. An agent whose triggers are ALL `is_active: false` has nothing firing it: check `is_active` before concluding an agent is scheduled or event-driven. `cron_expressions`/`schedule` remain for back-compat (they already reflect only active schedule triggers).",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "description": "Raw trigger UUID (no prefix)."
                },
                "trigger_type": {
                  "type": "string",
                  "description": "Known values: `schedule` (= cron) and the `*_webhook` types that fire on external events (`plaid_webhook`, `ramp_webhook`, `quickbooks_webhook`, `xero_webhook`, `email_webhook`). Not a closed enum — new trigger types on the chats side flow through unchanged, so treat an unrecognized value as a webhook-style event trigger rather than an error."
                },
                "is_active": {
                  "type": "boolean",
                  "description": "Whether this trigger is enabled. Only `is_active: true` triggers actually fire the agent — a paused (`false`) trigger is retained but does not run it."
                },
                "cron_expressions": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "description": "Cron expressions for a `schedule` trigger; empty for webhook triggers."
                },
                "event_types": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "description": "Event names a webhook trigger listens for; empty for a `schedule` trigger."
                },
                "last_triggered_at": {
                  "type": ["string", "null"],
                  "format": "date-time"
                },
                "created_at": {
                  "type": ["string", "null"],
                  "format": "date-time"
                },
                "updated_at": {
                  "type": ["string", "null"],
                  "format": "date-time"
                }
              }
            }
          },
          "arguments": {
            "type": "object",
            "description": "Agent-specific config. Scope lives here: `legalEntityIds` (each a `le_`-prefixed legal-entity id) and `sourceIds` (raw source ids — a source's type, hence its `wal_`/`exs_`/`fac_` prefix, is not stored on the agent)."
          },
          "emails": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Email notification recipients."
          },
          "slack_channel_id": {
            "type": ["string", "null"],
            "description": "Slack channel ID for notifications."
          },
          "organization_id": {
            "type": "string"
          },
          "last_run_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "last_error_message": {
            "type": ["string", "null"],
            "description": "Error message from the most recent failed run."
          },
          "latest_run": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/AgentRun"
              },
              {
                "type": "null"
              }
            ],
            "description": "Most recent run. Only included in GET /agents/{id}."
          },
          "last_agent_run_status": {
            "type": ["string", "null"],
            "description": "Latest run status. Only included in list responses."
          },
          "owner_user_id": {
            "type": ["string", "null"],
            "description": "Prefixed `usr_` id of the assignable owner (defaults to the creator), or null."
          },
          "reviewer_user_id": {
            "type": ["string", "null"],
            "description": "Prefixed `usr_` id of the assignable run reviewer, or null."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AgentRun": {
        "type": "object",
        "required": ["id", "agent_instance_id", "status", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "examples": ["run_xyz789"]
          },
          "agent_instance_id": {
            "type": "string"
          },
          "run_instance_arguments": {
            "type": "object",
            "description": "Arguments used in this specific run."
          },
          "status": {
            "type": "string",
            "enum": ["in_progress", "success", "error", "pending_review"],
            "description": "`pending_review` = a successful run of an instance with an assigned reviewer, awaiting approval; approving transitions it to `success`."
          },
          "success_data": {
            "type": ["string", "null"],
            "description": "Result data on success."
          },
          "error_data": {
            "type": ["string", "null"],
            "description": "Error details on failure."
          },
          "error_message": {
            "type": ["string", "null"],
            "description": "Human-readable error message."
          },
          "reviewed_by": {
            "type": ["string", "null"],
            "description": "Prefixed `usr_` id of the approver, or null if auto-released (reviewer removed) or never reviewed."
          },
          "reviewed_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When the run was approved or auto-released, or null."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Webhook": {
        "type": "object",
        "required": [
          "id",
          "organization_id",
          "endpoint",
          "event_types",
          "status",
          "description",
          "failure_count",
          "last_delivered_at",
          "created_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed subscription ID (`whk_` prefix).",
            "examples": ["whk_345def"]
          },
          "organization_id": {
            "type": "string",
            "description": "Prefixed organization ID (`org_` prefix)."
          },
          "endpoint": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS destination URL that receives signed event deliveries."
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Subscribed event-type glob patterns (e.g. `entendre.journal_entry.*` or `*`)."
          },
          "status": {
            "type": "string",
            "enum": ["active", "paused", "disabled"],
            "description": "`active` (delivering), `paused` (caller-suspended), or `disabled` (auto-disabled after 50 consecutive delivery failures). Re-enable by patching `status: \"active\"`."
          },
          "description": {
            "type": ["string", "null"],
            "description": "Caller-supplied label, or `null`."
          },
          "failure_count": {
            "type": "integer",
            "description": "Consecutive delivery failures since the last success. Resets to 0 on the next 2xx delivery (or when re-activated)."
          },
          "last_delivered_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "Timestamp of the most recent successful (2xx) delivery, or `null` if none yet."
          },
          "created_by": {
            "type": ["string", "null"],
            "description": "Prefixed user ID (`usr_` prefix) of the creator, or `null`."
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "WebhookWithSecret": {
        "description": "Returned only on creation. The `secret` is never returned again.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Webhook"
          },
          {
            "type": "object",
            "properties": {
              "secret": {
                "type": "string",
                "description": "HMAC-SHA256 signing secret. Store securely."
              }
            },
            "required": ["secret"]
          }
        ]
      },
      "WebhookEvent": {
        "type": "object",
        "description": "Event payload delivered to your webhook endpoint. Verify the `Entendre-Signature` HMAC-SHA256 header before processing.",
        "required": ["id", "event_type", "created_at", "data"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Unique event ID (`evt_` prefix).",
            "examples": ["evt_678ghi"]
          },
          "event_type": {
            "type": "string",
            "enum": [
              "entendre.transaction.created",
              "entendre.transaction.updated",
              "entendre.transaction.bulk_updated",
              "entendre.transaction.deleted",
              "entendre.journal_entry.created",
              "entendre.journal_entry.posted",
              "entendre.journal_entry.unposted",
              "entendre.journal_entry.deleted",
              "entendre.accounting_period.opened",
              "entendre.accounting_period.closed",
              "entendre.gl_sync.completed",
              "entendre.gl_sync.failed",
              "entendre.webhook_subscription.created",
              "entendre.webhook_subscription.updated",
              "entendre.webhook_subscription.deleted",
              "entendre.cash_application.created",
              "entendre.cash_application.applied",
              "entendre.cash_application.updated",
              "entendre.cash_application.unapplied",
              "entendre.cash_application.voided",
              "entendre.cash_application.stripe_synced",
              "entendre.bill_payment.created",
              "entendre.bill_payment.deleted",
              "entendre.bill.updated",
              "entendre.account_posting_rule.created",
              "entendre.account_posting_rule.updated",
              "entendre.account_posting_rule.deleted",
              "entendre.card_sync.completed",
              "entendre.statement_line.matched",
              "entendre.statement_line.unmatched",
              "entendre.financial_account.created",
              "entendre.financial_account.updated",
              "entendre.financial_account.deleted",
              "entendre.ledger_account.created",
              "entendre.ledger_account.updated",
              "entendre.ledger_account.deleted",
              "entendre.tag.created",
              "entendre.tag.updated",
              "entendre.tag.deleted",
              "entendre.legal_entity.created",
              "entendre.legal_entity.updated",
              "entendre.legal_entity.deleted",
              "entendre.template.created",
              "entendre.template.updated",
              "entendre.template.deleted"
            ],
            "description": "The literal event type that fired, of the form `entendre.<resource>.<action>`. Subscriptions match these via `event_types` glob patterns."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the event was generated."
          },
          "data": {
            "type": "object",
            "description": "Event-specific payload. Shape varies by `event_type`. See the Event Catalog in the Webhooks guide for full examples per event type."
          }
        },
        "example": {
          "id": "evt_678ghi",
          "event_type": "entendre.transaction.created",
          "created_at": "2024-03-10T14:31:00Z",
          "data": {
            "id": "txn_8923749823",
            "account_id": "fac_0987654321",
            "legal_entity_id": "le_456def",
            "source_class": "crypto",
            "external": {
              "timestamp": "2024-03-09T10:15:30Z",
              "direction": "credit",
              "quantity": {
                "gross": "1.5",
                "fee": "0.003",
                "net": "1.497"
              },
              "currency": "ETH",
              "value_fiat": {
                "gross": "5250.00",
                "fee": "12.50",
                "net": "5237.50",
                "currency": "USD"
              }
            },
            "internal": {
              "status": "posted",
              "category": "DEPOSIT",
              "memo": null
            }
          }
        }
      },
      "ApiKeyPermissions": {
        "description": "API-key scope: a single preset OR an array of concrete v1 scopes; do not mix the two. '*' is not mintable.",
        "oneOf": [
          {
            "type": "array",
            "items": { "type": "string", "enum": ["apis.all", "apis.read"] },
            "minItems": 1,
            "maxItems": 1,
            "description": "Preset: [\"apis.all\"] (full access) or [\"apis.read\"] (all read scopes)."
          },
          {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "read:accounting-period",
                "write:accounting-period",
                "read:accruals",
                "write:accruals",
                "read:activities",
                "write:activities",
                "read:agents",
                "write:agents",
                "read:analytics",
                "read:api-keys",
                "admin:api-keys",
                "read:asset-blacklist",
                "admin:asset-blacklist",
                "read:assets",
                "write:assets",
                "read:audit-event",
                "read:background-jobs",
                "read:balance",
                "read:cards",
                "write:cards",
                "read:cash-application",
                "write:cash-application",
                "write:classifications",
                "read:email-ingestion",
                "admin:email-ingestion",
                "write:events",
                "read:financial-accounts",
                "write:financial-accounts",
                "read:gl",
                "write:gl",
                "admin:gl",
                "read:gl-sync",
                "write:gl-sync",
                "read:integrations",
                "read:jobs",
                "write:jobs",
                "read:journal-entries",
                "write:journal-entries",
                "read:ledger-account",
                "write:ledger-account",
                "read:legal-entity",
                "write:legal-entity",
                "read:notifications",
                "write:notifications",
                "read:oauth-connections",
                "admin:oauth-connections",
                "read:operational-rules",
                "write:operational-rules",
                "read:organizations",
                "write:organizations",
                "read:prepaid",
                "write:prepaid",
                "read:reports",
                "read:settings",
                "write:settings",
                "read:sources",
                "write:sources",
                "read:spam-tokens",
                "write:spam-tokens",
                "read:table-groups",
                "write:table-groups",
                "read:tags",
                "write:tags",
                "read:tax",
                "write:tax",
                "read:templates",
                "write:templates",
                "read:transactions",
                "write:transactions",
                "read:users",
                "write:users",
                "read:vault",
                "write:vault",
                "read:webhook",
                "write:webhook",
                "read:whitelist-tokens",
                "write:whitelist-tokens"
              ]
            },
            "minItems": 1,
            "description": "Restricted: an array of concrete action:resource scopes, e.g. [\"read:assets\", \"write:gl\"]."
          }
        ]
      },
      "OAuthTokenResponse": {
        "type": "object",
        "required": ["access_token", "token_type", "expires_in", "refresh_token", "scope"],
        "properties": {
          "access_token": {
            "type": "string",
            "description": "Bearer token for API requests — a scoped, short-lived `ek_live_` API key minted through the standard API-key store."
          },
          "token_type": {
            "type": "string",
            "const": "Bearer"
          },
          "expires_in": {
            "type": "integer",
            "description": "Access-token lifetime in seconds (86400 = 1 day)."
          },
          "refresh_token": {
            "type": "string",
            "description": "Use to get a new access token without re-prompting. An opaque 64-character hex string (no prefix). Expires after 90 days of inactivity; each use resets the window and issues a new refresh token (rotation)."
          },
          "scope": {
            "type": "string",
            "description": "Space-separated list of granted scopes."
          }
        }
      },
      "JournalEntryFilterBody": {
        "type": "object",
        "description": "Filter for a bulk journal-entry operation. At least one field is required — an empty body is rejected (no mass mutation). Mirrors the GET /journal-entries filter set. Mutually-exclusive selectors compose as an AND.",
        "properties": {
          "journal_entry_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 5000,
            "description": "Prefixed JE IDs (je_...)"
          },
          "journal_sequence_numbers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Sequence numbers (e.g. JE-2096) or bare integers"
          },
          "transaction_sequence_numbers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Source transaction sequence numbers (resolved to transactions server-side)"
          },
          "template_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 5000,
            "description": "Prefixed template IDs (tpl_...)"
          },
          "ledger_account_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 5000,
            "description": "Prefixed ledger-account IDs (lac_...)"
          },
          "legal_entity_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 5000,
            "description": "Prefixed legal-entity IDs (le_...)"
          },
          "accounting_period_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 5000,
            "description": "Prefixed accounting-period IDs (ap_...)"
          },
          "accounting_period_start_date_utcs": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Target accounting periods by UTC start date (e.g. 2024-06-01T00:00:00.000Z). Legacy parity; accepted alongside accounting_period_ids."
          },
          "start_date": {
            "type": "string",
            "format": "date-time",
            "description": "Inclusive lower bound on accounting date (ISO 8601)"
          },
          "end_date": {
            "type": "string",
            "format": "date-time",
            "description": "Upper bound on accounting date (ISO 8601)"
          },
          "statuses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": ["DRAFT", "IN_PROGRESS", "POSTED", "UNPOSTED", "REVERSED", "ERROR"]
            },
            "description": "Entry statuses (case-insensitive). Defaults are endpoint-specific (unpost: POSTED+REVERSED; delete: DRAFT+ERROR+UNPOSTED)."
          },
          "synced_start_date": {
            "type": "string",
            "format": "date-time",
            "description": "Inclusive lower bound on `last_synced_at` — the durable \"when was this pushed to the GL\" stamp that survives unsync. Matches entries synced in the range even if they were unsynced afterwards."
          },
          "synced_end_date": {
            "type": "string",
            "format": "date-time",
            "description": "Inclusive upper bound on `last_synced_at`. Must be on or after synced_start_date."
          },
          "sync_history_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "pattern": "^sh_[0-9a-fA-F]{24}$"
            },
            "description": "Target the member set of specific sync batches (ids from GET /gl-sync/history) via the entry's durable batch stamp. Matches later-unsynced members too."
          },
          "confirm_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Optional scope guard. When provided, MUST equal the resolved match count (preview it with dry_run:true), otherwise the request is rejected with 400. Rejected when the match set was truncated — narrow the filter instead."
          },
          "dry_run": {
            "type": "boolean",
            "description": "When true, previews the scope without mutating anything: returns `{ summary: { matched, truncated }, eligible: [je_...] }` — the matched count plus the eligible entry ids. There is no per-row `failed` list on this filter path (every matched entry is eligible). Preview first, then pass `confirm_count` equal to `summary.matched` to commit."
          }
        }
      },
      "BulkTagResult": {
        "type": "object",
        "properties": {
          "succeeded": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of journal entries that were updated"
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "reason": {
                  "type": "string"
                }
              }
            }
          },
          "lines_modified": {
            "type": "integer",
            "description": "Number of JE lines that received new tags"
          }
        },
        "required": ["succeeded", "failed", "lines_modified"]
      },
      "BulkReclassResult": {
        "oneOf": [
          { "$ref": "#/components/schemas/BulkReclassPreview" },
          { "$ref": "#/components/schemas/BulkReclassCommit" }
        ],
        "description": "Two distinct shapes: a `dry_run: true` request returns a `BulkReclassPreview` (nothing is written); a real request returns a `BulkReclassCommit`."
      },
      "BulkReclassPreview": {
        "type": "object",
        "description": "Returned ONLY for a `dry_run: true` preview — no entries are unposted or created.",
        "properties": {
          "dry_run": {
            "type": "boolean",
            "description": "Always `true` on a preview."
          },
          "would_process": {
            "type": "integer",
            "description": "How many journal entries WOULD be reclassified. Counts only entries that clear every pre-mutation gate, so it never overstates the real run."
          },
          "skipped": {
            "type": "integer",
            "description": "Count of matched journal entries that would NOT reclassify (see `skipped_reasons`)."
          },
          "skipped_reasons": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "reason": { "type": "string" }
              }
            },
            "description": "Up to 10 sampled entries that would NOT reclassify, each with why (ambiguous auto-detect, missing source/transaction, a multi-line entry the fallback would collapse, or a template-less JE whose payment account can't be derived)."
          },
          "samples": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": ["string", "null"] },
                "sequence_number": { "type": "string" }
              }
            },
            "description": "Up to 10 sampled journal entries that WOULD reclassify (id + sequence number)."
          }
        },
        "required": ["dry_run", "would_process", "skipped", "skipped_reasons", "samples"]
      },
      "BulkReclassCommit": {
        "type": "object",
        "description": "Returned for a real (non-preview) reclass.",
        "properties": {
          "succeeded": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of newly created journal entries."
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "reason": {
                  "type": "string"
                }
              }
            },
            "description": "Entries that could not be reclassified, each with why. Entries whose source account already equals the target ('nothing to reclassify') are reported here."
          },
          "skipped": {
            "type": "integer",
            "description": "Always 0 on a real run — retained for response-shape stability. Ineligible entries, INCLUDING any already on the target account, are reported in `failed`, not counted here."
          }
        },
        "required": ["succeeded", "failed", "skipped"]
      },
      "LineTagResult": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "jel_abc123"
          },
          "journal_entry_id": {
            "type": "string",
            "example": "je_def456"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": ["id", "journal_entry_id", "tags"]
      },
      "OrganizationFull": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "examples": ["org_abc123"]
          },
          "name": {
            "type": "string"
          },
          "web_address": {
            "type": ["string", "null"]
          },
          "timezone": {
            "type": "string"
          },
          "time_format": {
            "type": "string",
            "enum": ["hr12", "hr24"]
          },
          "onboarding_status": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "name"]
      },
      "OrganizationMember": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed member ID (`mbr_`).",
            "examples": ["mbr_abc123"]
          },
          "user_id": {
            "type": ["string", "null"],
            "examples": ["usr_def456"]
          },
          "email": {
            "type": ["string", "null"]
          },
          "name": {
            "type": ["string", "null"]
          },
          "role": {
            "type": ["string", "null"],
            "enum": ["admin", "accountant", "auditor", "analyst", null],
            "description": "Organization role, lowercased."
          },
          "status": {
            "type": ["string", "null"],
            "description": "Membership status, lowercased (e.g. `invited`, `accepted`)."
          },
          "invited_by": {
            "type": ["string", "null"],
            "examples": ["usr_ghi789"]
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id"]
      },
      "User": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed user ID (`usr_`).",
            "examples": ["usr_abc123"]
          },
          "email": {
            "type": "string"
          },
          "name": {
            "type": "object",
            "properties": {
              "first": {
                "type": ["string", "null"]
              },
              "last": {
                "type": ["string", "null"]
              }
            }
          },
          "phone_number": {
            "type": ["string", "null"]
          },
          "address": {
            "type": "object",
            "properties": {
              "line1": {
                "type": ["string", "null"]
              },
              "line2": {
                "type": ["string", "null"]
              },
              "city": {
                "type": ["string", "null"]
              },
              "state": {
                "type": ["string", "null"]
              },
              "country": {
                "type": ["string", "null"]
              }
            }
          },
          "billing_email": {
            "type": ["string", "null"]
          },
          "email_verified": {
            "type": "boolean"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "email"]
      },
      "RevaluationSettings": {
        "type": "object",
        "properties": {
          "legal_entity_id": {
            "type": "string",
            "examples": ["le_abc123"]
          },
          "setting": {
            "type": "string",
            "enum": ["single", "tuple"]
          },
          "gain_ledger_account_id": {
            "type": ["string", "null"],
            "examples": ["lac_def456"]
          },
          "loss_ledger_account_id": {
            "type": ["string", "null"],
            "examples": ["lac_ghi789"]
          },
          "ledger_account_id": {
            "type": ["string", "null"],
            "examples": ["lac_jkl012"]
          }
        },
        "required": ["legal_entity_id", "setting"]
      },
      "JobConfig": {
        "type": "object",
        "properties": {
          "id": {
            "type": ["string", "null"],
            "examples": ["job_abc123"]
          },
          "job_type": {
            "type": "string"
          },
          "status": {
            "type": ["string", "null"]
          },
          "cron_expression": {
            "type": ["string", "null"]
          },
          "last_execution_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "next_execution_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["job_type"]
      },
      "JobProgress": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "examples": ["job_abc123"]
          },
          "job_type": {
            "type": "string",
            "description": "Job name, lowercased (e.g. `import_transaction_job`). Note: the `job_type` query filter is case-insensitive and canonically UPPERCASE."
          },
          "status": {
            "type": ["string", "null"],
            "enum": ["started", "in_progress", "completed", "job_failed", "canceled", "hanged", null],
            "description": "Job status, lowercased. Note: the `status` query filter is case-insensitive and canonically UPPERCASE."
          },
          "to_process_count": {
            "type": "integer"
          },
          "completed_count": {
            "type": "integer"
          },
          "progress": {
            "type": "number",
            "description": "Percentage 0-100."
          },
          "message": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "job_type"]
      },
      "JobHistoryEntry": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "examples": ["job_abc123"]
          },
          "job_type": {
            "type": "string",
            "description": "Job name, lowercased (e.g. `import_transaction_job`). Note: the `job_type` query filter is case-insensitive and canonically UPPERCASE."
          },
          "status": {
            "type": ["string", "null"],
            "enum": ["started", "in_progress", "completed", "job_failed", "canceled", "hanged", null],
            "description": "Job status, lowercased. Note: the `status` query filter is case-insensitive and canonically UPPERCASE."
          },
          "to_process_count": {
            "type": "integer"
          },
          "completed_count": {
            "type": "integer"
          },
          "message": {
            "type": ["string", "null"],
            "description": "Status/failure reason (e.g. the error message for a `job_failed` row). Null when there is none."
          },
          "started_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "completed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "job_type"]
      },
      "Asset": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed asset ID (`ast_`).",
            "examples": ["ast_abc123"]
          },
          "asset_type": {
            "type": "string",
            "examples": ["ETH"]
          },
          "quantity": {
            "type": "string",
            "description": "Decimal string.",
            "examples": ["10.5"]
          },
          "remaining_quantity": {
            "type": "string",
            "examples": ["8.25"]
          },
          "cost_basis": {
            "type": "string",
            "examples": ["15750.00"]
          },
          "currency": {
            "type": "string",
            "examples": ["USD"]
          },
          "date_received": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "chain": {
            "type": ["string", "null"]
          },
          "legal_entity_id": {
            "type": ["string", "null"],
            "examples": ["le_456def"]
          },
          "ledger_account_id": {
            "type": ["string", "null"],
            "examples": ["lac_789ghi"]
          },
          "transaction_id": {
            "type": ["string", "null"],
            "examples": ["txn_012jkl"]
          },
          "source_id": {
            "type": ["string", "null"],
            "examples": ["fac_345mno"]
          },
          "is_manual": {
            "type": "boolean"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "asset_type", "quantity", "remaining_quantity", "cost_basis", "currency"]
      },
      "AssetMetrics": {
        "type": "object",
        "properties": {
          "total_cost_basis": {
            "type": "string",
            "examples": ["1250000.00"]
          },
          "total_market_value": {
            "type": "string",
            "examples": ["1500000.00"]
          },
          "total_unrealized_gain": {
            "type": "string",
            "examples": ["300000.00"]
          },
          "total_unrealized_loss": {
            "type": "string",
            "examples": ["50000.00"]
          },
          "asset_count": {
            "type": "integer",
            "examples": [142]
          },
          "by_type": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "asset_type": {
                  "type": "string"
                },
                "quantity": {
                  "type": "string"
                },
                "cost_basis": {
                  "type": "string"
                }
              }
            }
          }
        },
        "required": ["total_cost_basis", "total_market_value", "asset_count", "by_type"]
      },
      "TokenBalance": {
        "type": "object",
        "properties": {
          "financial_account_id": {
            "type": ["string", "null"],
            "examples": ["fac_abc123"]
          },
          "chain": {
            "type": "string",
            "examples": ["ethereum"]
          },
          "token": {
            "type": "string",
            "examples": ["USDC"]
          },
          "balance": {
            "type": "string",
            "examples": ["1500.50"]
          },
          "fiat_value": {
            "type": "string",
            "examples": ["1500.50"]
          },
          "fiat_currency": {
            "type": "string",
            "examples": ["USD"]
          },
          "is_native": {
            "type": "boolean"
          },
          "price_change_24h": {
            "type": ["number", "null"]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": ["chain", "token", "balance", "fiat_value", "fiat_currency"]
      },
      "ExchangeBalance": {
        "type": "object",
        "properties": {
          "financial_account_id": {
            "type": ["string", "null"],
            "examples": ["fac_abc123"]
          },
          "legal_entity_id": {
            "type": ["string", "null"],
            "examples": ["le_def456"]
          },
          "provider": {
            "type": "string",
            "examples": ["binance"]
          },
          "asset": {
            "type": "string",
            "examples": ["BTC"]
          },
          "balance": {
            "type": "string",
            "examples": ["2.5"]
          },
          "fiat_value": {
            "type": "string",
            "examples": ["125000.00"]
          },
          "fiat_currency": {
            "type": "string",
            "examples": ["USD"]
          },
          "is_native": {
            "type": "boolean"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": ["provider", "asset", "balance", "fiat_value", "fiat_currency"]
      },
      "DecimalValue": {
        "type": "object",
        "description": "Decimal amount wrapped as an object — `value` is a decimal string, never a float. Mirrors how the platform serializes big-decimal amounts on report rows.",
        "required": ["value"],
        "properties": {
          "value": {
            "type": "string",
            "examples": ["1250.00"]
          }
        }
      },
      "FinancialReportRow": {
        "type": "object",
        "description": "Balance data for one ledger account in one accounting period. Faithful projection of the internal `ReportResponseV2` row consumed by the shared balance-sheet table: decimals are `{ value }` objects, IDs are prefixed.",
        "required": ["credit_debit", "tag_balance"],
        "properties": {
          "credit_debit": {
            "type": "object",
            "required": [
              "opening_balance",
              "closing_balance",
              "current_balance",
              "debits",
              "credits",
              "accounting_period_start_date_utc",
              "legal_entity_ids"
            ],
            "properties": {
              "opening_balance": {
                "$ref": "#/components/schemas/DecimalValue"
              },
              "closing_balance": {
                "$ref": "#/components/schemas/DecimalValue"
              },
              "current_balance": {
                "$ref": "#/components/schemas/DecimalValue"
              },
              "debits": {
                "$ref": "#/components/schemas/DecimalValue"
              },
              "credits": {
                "$ref": "#/components/schemas/DecimalValue"
              },
              "accounting_period_start_date_utc": {
                "type": ["string", "null"],
                "format": "date-time",
                "description": "UTC start date of the accounting period this row belongs to."
              },
              "legal_entity_ids": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Prefixed legal entity IDs (`le_`) whose balances contribute to this row."
              }
            }
          },
          "tag_balance": {
            "$ref": "#/components/schemas/DecimalValue",
            "description": "Balance attributable to the requested tag. `0` unless the report was filtered with `tag_id`."
          }
        }
      },
      "OverviewReport": {
        "type": "object",
        "properties": {
          "total_portfolio": {
            "type": "object",
            "properties": {
              "value": {
                "type": "string"
              },
              "change": {
                "type": "string"
              },
              "change_percentage": {
                "type": "string"
              }
            }
          },
          "monthly_income": {
            "type": "object",
            "properties": {
              "value": {
                "type": "string"
              },
              "change": {
                "type": "string"
              },
              "change_percentage": {
                "type": "string"
              }
            }
          },
          "monthly_expenses": {
            "type": "object",
            "properties": {
              "value": {
                "type": "string"
              },
              "change": {
                "type": "string"
              },
              "change_percentage": {
                "type": "string"
              }
            }
          },
          "computed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the returned figures were computed (ISO 8601); `null` if the org has no overview data yet. For a live request this is \"now\"."
          },
          "is_stale": {
            "type": "boolean",
            "description": "True when the figures came from a cache older than 24h. If true, prefer the live treasury/balances feed for a point-in-time answer."
          }
        }
      },
      "ClosingPosition": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string"
          },
          "quantity": {
            "type": "string"
          },
          "cost_basis": {
            "type": "string"
          },
          "weighted_average_cost": {
            "type": "string"
          },
          "current_price": {
            "type": "string"
          },
          "market_value": {
            "type": "string"
          },
          "unrealized_gain": {
            "type": "string"
          },
          "date": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "AssetRollForwardRow": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string"
          },
          "open_running_balance": {
            "type": "string"
          },
          "open_historical_balance": {
            "type": "string"
          },
          "open_variance": {
            "type": "string"
          },
          "inflows": {
            "type": "string"
          },
          "outflows": {
            "type": "string"
          },
          "movement_net": {
            "type": "string"
          },
          "close_running_balance": {
            "type": "string"
          },
          "close_historical_balance": {
            "type": "string"
          },
          "close_variance": {
            "type": "string"
          }
        }
      },
      "WalletBreakdown": {
        "type": "object",
        "properties": {
          "wallet": {
            "type": "string"
          },
          "chain": {
            "type": "string"
          },
          "address": {
            "type": "string"
          },
          "subledger_balance": {
            "type": "string"
          },
          "on_chain_balance": {
            "type": "string"
          },
          "delta": {
            "type": "string"
          }
        }
      },
      "DispositionRecord": {
        "type": "object",
        "properties": {
          "asset_type": {
            "type": "string"
          },
          "asset_record": {
            "type": "string"
          },
          "date_received": {
            "type": "string",
            "format": "date-time"
          },
          "quantity": {
            "type": "string"
          },
          "remaining_quantity": {
            "type": "string"
          },
          "cost_basis": {
            "type": "string"
          },
          "disposals": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "sale_date": {
                  "type": "string",
                  "format": "date-time"
                },
                "quantity_sold": {
                  "type": "string"
                },
                "sale_price": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "BalanceReconciliation": {
        "type": "object",
        "properties": {
          "balances": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "address": {
                  "type": "string"
                },
                "chain": {
                  "type": "string"
                },
                "symbol": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "on_chain_balance": {
                  "type": "string",
                  "description": "Interval NET MOVEMENT (received − sent over the window), NOT an absolute holding — legitimately negative on a net-outflow period."
                },
                "start_on_chain_balance": {
                  "type": ["string", "null"],
                  "description": "Absolute on-chain holding at the start of the window (null when unavailable). This is what the negative-holding data-quality warning inspects."
                },
                "end_on_chain_balance": {
                  "type": ["string", "null"],
                  "description": "Absolute on-chain holding at the end of the window (null when unavailable). Use THIS (not on_chain_balance) for an absolute holdings-vs-books check as of end_date."
                },
                "ledger_balance": {
                  "type": "string",
                  "description": "NET ledger movement (signed netAmount sum) within [start_date, end_date], NOT an absolute balance as of end_date. Equals the true balance only when start_date predates all activity."
                },
                "delta": {
                  "type": "string",
                  "description": "on_chain_balance − ledger_balance, i.e. a MOVEMENT-vs-movement difference over the window. A near-zero delta on a narrow window means 'no net movement mismatch this window', NOT 'holdings reconcile'. For an absolute reconciliation compare end_on_chain_balance against the books."
                }
              }
            }
          },
          "total_addresses": {
            "type": "integer"
          },
          "data_quality": {
            "type": "object",
            "properties": {
              "is_complete": {
                "type": "boolean",
                "description": "FETCH completeness only — true when every chain/source returned data (no failed or partial-data chains). It does NOT mean the balances reconcile (delta ≈ 0). In the default strict mode incomplete fetches return 503, so a 200 response effectively always carries is_complete: true; never read it as 'balances match'."
              },
              "failed_chains": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "partial_data_chains": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "warnings": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "failure_rate": {
                "type": "number"
              }
            }
          }
        }
      },
      "ReportExportEnvelope": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "properties": {
              "job_id": {
                "type": "string",
                "description": "Prefixed job id (`rxj_<uuid>`). Use as `:id` in `GET /v1/background-jobs/:id`."
              },
              "status": {
                "type": "string",
                "enum": ["queued", "in_progress", "completed", "failed", "canceled"]
              },
              "type": {
                "type": "string",
                "description": "Background-job type discriminator (e.g. `report_export`)."
              },
              "format": {
                "type": "string",
                "enum": ["csv", "xlsx"]
              },
              "from_cache": {
                "type": "boolean",
                "description": "`true` when the request matched an existing in-flight job and was deduped."
              }
            }
          }
        }
      },
      "ClassificationJobEnvelope": {
        "type": "object",
        "required": ["data"],
        "properties": {
          "data": {
            "type": "object",
            "required": ["job_id", "status", "type"],
            "properties": {
              "job_id": {
                "type": "string",
                "description": "Pollable classification job id (`cls_<uuid>`)."
              },
              "status": {
                "type": "string",
                "enum": ["queued", "in_progress", "completed", "failed", "canceled"]
              },
              "type": {
                "type": "string",
                "enum": ["classification"]
              }
            }
          }
        }
      },
      "ClassificationTotals": {
        "type": "object",
        "required": ["input", "classified", "pending_review", "unclassified", "auto_posted"],
        "properties": {
          "input": { "type": "integer" },
          "classified": { "type": "integer" },
          "pending_review": { "type": "integer" },
          "unclassified": { "type": "integer" },
          "auto_posted": { "type": "integer" }
        }
      },
      "ClassificationItem": {
        "type": "object",
        "required": [
          "transactionId",
          "transactionVersion",
          "postingAmount",
          "sequenceNumber",
          "amount",
          "direction",
          "classification",
          "sourceCategory",
          "merchantName",
          "memo",
          "categoryLedgerAccountId",
          "categoryLedgerAccountName",
          "categoryLedgerAccountSequence",
          "paymentAccountId",
          "confidence",
          "matchSource",
          "reason",
          "needsUserInput",
          "missingFields",
          "suggestedTagId",
          "vendorName"
        ],
        "properties": {
          "transactionId": {
            "type": "string",
            "description": "Canonical prefixed transaction id (`txn_…`)."
          },
          "transactionVersion": {
            "type": "string",
            "format": "date-time",
            "description": "Transaction version observed by the classification run."
          },
          "postingAmount": {
            "type": "string",
            "description": "Fiat amount used by the classification posting handler."
          },
          "sequenceNumber": { "type": ["string", "null"], "description": "Customer-facing OT-… number." },
          "amount": { "type": "number" },
          "direction": { "type": "string", "enum": ["DEPOSIT", "WITHDRAWAL"] },
          "classification": { "type": ["string", "null"] },
          "sourceCategory": {
            "type": ["string", "null"],
            "description": "Raw provider category signal, when present; not an accounting treatment."
          },
          "merchantName": { "type": ["string", "null"] },
          "memo": { "type": ["string", "null"] },
          "categoryLedgerAccountId": {
            "type": "string",
            "description": "Matched Entendre category account (`lac_…`), or an empty string when unclassified."
          },
          "categoryLedgerAccountName": { "type": "string" },
          "categoryLedgerAccountSequence": { "type": ["integer", "null"] },
          "paymentAccountId": {
            "type": ["string", "null"],
            "description": "Derived Entendre payment account (`lac_…`)."
          },
          "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
          "matchSource": {
            "type": "string",
            "enum": ["rule", "history", "llm", "manual"]
          },
          "reason": { "type": "string" },
          "needsUserInput": { "type": "boolean" },
          "missingFields": { "type": "array", "items": { "type": "string" } },
          "suggestedTagId": { "type": ["string", "null"], "description": "Prefixed tag id (`tag_…`)." },
          "vendorName": { "type": ["string", "null"] }
        }
      },
      "ClassificationEntityIds": {
        "type": "object",
        "required": ["transaction_ids", "journal_entry_ids"],
        "properties": {
          "transaction_ids": { "type": "array", "items": { "type": "string" } },
          "journal_entry_ids": { "type": "array", "items": { "type": "string" } }
        }
      },
      "ClassificationResult": {
        "type": "object",
        "required": ["run_id", "totals", "classified", "pending_review", "unclassified", "auto_posted"],
        "properties": {
          "run_id": { "type": "string" },
          "totals": { "$ref": "#/components/schemas/ClassificationTotals" },
          "classified": { "type": "array", "items": { "$ref": "#/components/schemas/ClassificationItem" } },
          "pending_review": { "type": "array", "items": { "$ref": "#/components/schemas/ClassificationItem" } },
          "unclassified": { "type": "array", "items": { "$ref": "#/components/schemas/ClassificationItem" } },
          "auto_posted": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/ClassificationItem" },
            "description": "High-confidence rows posted immediately by this run."
          },
          "auto_post_result": { "$ref": "#/components/schemas/ClassificationAutoPostResult" },
          "entity_ids": { "$ref": "#/components/schemas/ClassificationEntityIds" }
        }
      },
      "ClassificationAutoPostJournalEntry": {
        "type": "object",
        "required": [
          "_id",
          "journalSequenceNumber",
          "status",
          "accountingDate",
          "originatedBy",
          "templateName",
          "periodName",
          "transactionSequenceNumber",
          "transactionId",
          "isSync",
          "memo",
          "totalDebits",
          "totalCredits",
          "replayed"
        ],
        "properties": {
          "_id": { "type": "string", "description": "Canonical prefixed journal-entry id (`je_…`)." },
          "journalSequenceNumber": { "type": "string" },
          "status": { "type": "string" },
          "accountingDate": { "type": "string" },
          "originatedBy": { "type": "string" },
          "templateName": {
            "type": ["string", "null"],
            "description": "Null; classification posting is template-free."
          },
          "periodName": { "type": ["string", "null"] },
          "transactionSequenceNumber": { "type": ["string", "null"] },
          "transactionId": { "type": ["string", "null"] },
          "isSync": {
            "type": "boolean",
            "description": "Whether the Entendre journal entry is already synced to the connected GL. Classification does not trigger GL sync."
          },
          "memo": { "type": ["string", "null"] },
          "totalDebits": { "type": "number" },
          "totalCredits": { "type": "number" },
          "replayed": {
            "type": "boolean",
            "description": "True when an idempotent retry recovered the already-created deterministic classification journal entry."
          }
        }
      },
      "ClassificationAutoPostResult": {
        "type": "object",
        "required": ["posted", "replayed", "skipped", "failed", "journalEntries"],
        "properties": {
          "posted": { "type": "integer" },
          "replayed": { "type": "integer" },
          "skipped": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["transactionId", "reason"],
              "properties": {
                "transactionId": { "type": "string" },
                "reason": { "type": "string" }
              }
            }
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["transactionId", "error"],
              "properties": {
                "transactionId": { "type": "string" },
                "error": { "type": "string" }
              }
            }
          },
          "journalEntries": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/ClassificationAutoPostJournalEntry" }
          }
        }
      },
      "BackgroundJob": {
        "type": "object",
        "description": "Canonical job resource returned by `GET /v1/background-jobs/:id`. The same shape is emitted for every supported job-type prefix (`caj_`, `rxj_`, `cls_`).",
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed job id."
          },
          "type": {
            "type": "string",
            "description": "Job-type discriminator. Per-type result shapes are documented under the producing endpoint (e.g. `report_export` → `POST /v1/reports/export`)."
          },
          "status": {
            "type": "string",
            "enum": ["queued", "in_progress", "completed", "failed", "canceled"]
          },
          "params": {
            "type": "object",
            "additionalProperties": true,
            "description": "Request parameters captured at enqueue time. Shape is type-specific."
          },
          "result": {
            "description": "Terminal result on success. Classifications expose the posted, pending-review, unclassified, skipped, and failed outcomes. Report-export download URLs are 24-hour bearer capabilities.",
            "oneOf": [
              { "$ref": "#/components/schemas/ClassificationResult" },
              {
                "type": "object",
                "additionalProperties": true
              },
              { "type": "null" }
            ]
          },
          "error": {
            "type": ["object", "null"],
            "properties": {
              "message": {
                "type": "string"
              },
              "kind": {
                "type": "string",
                "description": "Stable discriminator for the failure class. Examples: `unsupported_report_type`, `invalid_data_shape`, `generator_failed`, `enqueue_failed`."
              }
            }
          },
          "created_by": {
            "type": ["string", "null"],
            "description": "User who initiated the job, prefixed (`usr_…`)."
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "completed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "AssetBalanceByDayReport": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "examples": ["abl_abc123"]
          },
          "job_id": {
            "type": ["string", "null"],
            "examples": ["job_def456"]
          },
          "from_date": {
            "type": "string",
            "format": "date-time"
          },
          "to_date": {
            "type": "string",
            "format": "date-time"
          },
          "chains": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "asset_types": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "wallet_address": {
                  "type": "string"
                },
                "alias": {
                  "type": "string"
                },
                "chain": {
                  "type": "string"
                },
                "token": {
                  "type": "string"
                },
                "balances": {
                  "type": "object",
                  "additionalProperties": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "summary": {
            "type": ["object", "null"],
            "properties": {
              "total_dates_processed": {
                "type": "integer"
              },
              "success_count": {
                "type": "integer"
              },
              "error_count": {
                "type": "integer"
              },
              "total_records": {
                "type": "integer"
              }
            }
          },
          "wallet_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Prefixed financial account ids (`fac_`) the report was scoped to."
          },
          "balance_errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string"
                },
                "error": {
                  "type": "string"
                }
              }
            },
            "description": "Per-date balance fetch errors encountered while generating the report."
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        },
        "required": ["id", "from_date", "to_date"]
      },
      "AssetBalanceByDayJob": {
        "type": "object",
        "description": "Background-job handle for an asset-balances-by-day report. Poll via `GET /v1/jobs/{id}/status`.",
        "properties": {
          "id": {
            "type": ["string", "null"],
            "examples": ["job_def456"]
          },
          "unique_id": {
            "type": ["string", "null"]
          },
          "job_name": {
            "type": ["string", "null"]
          },
          "reference_id": {
            "type": ["string", "null"]
          },
          "job_status": {
            "type": ["string", "null"]
          },
          "to_process_count": {
            "type": ["integer", "null"]
          },
          "completed_count": {
            "type": ["integer", "null"]
          }
        }
      },
      "LiveTokenBalance": {
        "type": "object",
        "properties": {
          "token_address": {
            "type": ["string", "null"]
          },
          "symbol": {
            "type": "string"
          },
          "balance": {
            "type": "string",
            "description": "Native balance as a decimal string."
          },
          "fiat_value": {
            "type": "string",
            "description": "Fiat value as a decimal string."
          },
          "price": {
            "type": ["string", "null"]
          },
          "price_change_24h": {
            "type": ["string", "null"]
          },
          "wallet_address": {
            "type": ["string", "null"]
          }
        },
        "required": ["symbol", "balance", "fiat_value"]
      },
      "LiveSourceBalance": {
        "type": "object",
        "description": "One source group: a wallet, an exchange account, or a bank account, with its per-token rows.",
        "properties": {
          "financial_account_id": {
            "type": ["string", "null"],
            "description": "Prefixed (`fac_`) wallet / exchange-source / bank-account id."
          },
          "source_class": {
            "type": "string",
            "enum": ["crypto", "exchange", "bank"]
          },
          "chain": {
            "type": "string"
          },
          "chain_id": {
            "type": "string"
          },
          "address": {
            "type": ["string", "null"]
          },
          "exchange_type": {
            "type": ["string", "null"]
          },
          "institution_name": {
            "type": ["string", "null"]
          },
          "entity_name": {
            "type": ["string", "null"]
          },
          "alias": {
            "type": ["string", "null"]
          },
          "legal_entity_id": {
            "type": ["string", "null"]
          },
          "source_type": {
            "type": ["string", "null"]
          },
          "historical_date": {
            "type": ["string", "null"]
          },
          "total_fiat": {
            "type": "string"
          },
          "error": {
            "type": ["string", "null"]
          },
          "tokens": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LiveTokenBalance"
            }
          }
        },
        "required": ["source_class", "chain", "chain_id", "total_fiat", "tokens"]
      },
      "V1GLConnectQuickBooks": {
        "type": "object",
        "title": "QuickBooks (OAuth)",
        "required": ["provider"],
        "properties": {
          "provider": {
            "type": "string",
            "enum": ["quickbooks"]
          },
          "redirect_url": {
            "type": "string",
            "format": "uri",
            "description": "Where the OAuth flow should return after consent. Either redirect_url or subdomain is required."
          },
          "subdomain": {
            "type": "string",
            "description": "Subdomain alias for legacy callers. Either redirect_url or subdomain is required."
          },
          "legal_entity_id": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "V1GLConnectXero": {
        "type": "object",
        "title": "Xero (OAuth)",
        "required": ["provider"],
        "properties": {
          "provider": {
            "type": "string",
            "enum": ["xero"]
          },
          "redirect_url": {
            "type": "string",
            "format": "uri",
            "description": "Where the OAuth flow should return after consent. Either redirect_url or subdomain is required."
          },
          "subdomain": {
            "type": "string",
            "description": "Subdomain alias for legacy callers. Either redirect_url or subdomain is required."
          },
          "legal_entity_id": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "V1GLConnectNetSuite": {
        "type": "object",
        "title": "NetSuite (OAuth)",
        "required": ["provider"],
        "properties": {
          "provider": {
            "type": "string",
            "enum": ["netsuite"]
          },
          "redirect_url": {
            "type": "string",
            "format": "uri",
            "description": "Where the OAuth flow should return after consent. Either redirect_url or subdomain is required."
          },
          "subdomain": {
            "type": "string",
            "description": "Subdomain alias for legacy callers. Either redirect_url or subdomain is required."
          },
          "legal_entity_id": {
            "type": "string",
            "nullable": true
          },
          "account_id": {
            "type": "string",
            "description": "Legacy field accepted for backwards compatibility and ignored. NetSuite OAuth is org-level and requires a pre-provisioned config."
          }
        }
      },
      "V1GLConnectDualEntry": {
        "type": "object",
        "title": "DualEntry (API key)",
        "required": ["provider", "api_key"],
        "properties": {
          "provider": {
            "type": "string",
            "enum": ["dualentry"]
          },
          "api_key": {
            "type": "string",
            "description": "Provider API key. Validated against the provider, then encrypted at rest."
          }
        }
      },
      "V1GLConnectCampfire": {
        "type": "object",
        "title": "Campfire (API key)",
        "required": ["provider", "api_key"],
        "properties": {
          "provider": {
            "type": "string",
            "enum": ["campfire"]
          },
          "api_key": {
            "type": "string",
            "description": "Provider API key. Validated against the provider, then encrypted at rest."
          }
        }
      },
      "V1GLConnection": {
        "type": "object",
        "required": ["id", "organization_id", "integration_type", "realm_id", "label", "token_status"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed GL auth ID (`gla_<24-hex>`).",
            "example": "gla_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "example": "org_abc123"
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
          },
          "realm_id": {
            "type": "string",
            "description": "Provider tenant identifier."
          },
          "label": {
            "type": "string",
            "description": "Human-friendly connection label, server-resolved as `entityName (companyName)` > entityName > companyName > realmId. Surface this instead of the raw realm_id for multi-realm orgs. Never empty (falls back to the realm_id)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "token_status": {
            "type": "string",
            "enum": ["active", "expired"],
            "description": "`expired` when the OAuth refresh token has lapsed and the user must reconnect. API-key providers (DualEntry, Campfire) are always `active`."
          }
        }
      },
      "V1GLReadiness": {
        "type": "object",
        "required": [
          "is_ready",
          "gl_connected",
          "gl_type",
          "vendors_synced",
          "vendors_mapped",
          "accrual_account_exists",
          "missing_configurations"
        ],
        "properties": {
          "is_ready": {
            "type": "boolean",
            "description": "True when the org has everything needed for GL-driven expense automation (no remaining blockers)."
          },
          "gl_connected": {
            "type": "boolean"
          },
          "gl_type": {
            "type": "string",
            "nullable": true,
            "description": "Connected provider (lowercase), or null when none is connected."
          },
          "vendors_synced": {
            "type": "integer"
          },
          "vendors_mapped": {
            "type": "integer"
          },
          "accrual_account_exists": {
            "type": "boolean"
          },
          "missing_configurations": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Human-readable list of remaining blockers; empty when is_ready is true."
          }
        }
      },
      "V1GLRawResponse": {
        "type": "object",
        "required": ["gl_type", "realm_id", "path", "query", "read_only", "data"],
        "properties": {
          "gl_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
          },
          "realm_id": {
            "type": "string"
          },
          "path": {
            "type": "string",
            "description": "Normalized provider-relative path that was queried."
          },
          "query": {
            "type": "object",
            "additionalProperties": true,
            "description": "Echo of the parsed query params sent to the provider."
          },
          "read_only": {
            "type": "boolean",
            "enum": [true]
          },
          "data": {
            "description": "Raw provider response payload (shape is provider- and path-specific)."
          }
        }
      },
      "V1GLConfig": {
        "type": "object",
        "required": ["id", "organization_id", "integration_type", "realm_id"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed GL config ID (`glc_<24-hex>`).",
            "example": "glc_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "example": "org_abc123"
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
          },
          "realm_id": {
            "type": "string"
          },
          "company_name": {
            "type": "string",
            "nullable": true
          },
          "legal_entity_id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed legal entity (`le_…`) when scoped."
          },
          "legal_entity_name": {
            "type": "string",
            "nullable": true
          },
          "should_rolled_up": {
            "type": "boolean",
            "nullable": true
          },
          "is_real_time": {
            "type": "boolean",
            "nullable": true,
            "description": "Whether real-time sync is ENABLED for this integration (a stored config toggle), NOT a freshness signal. A stale feed can still show is_real_time:true. For actual sync recency read `last_imported_date` / `last_import_attempt`."
          },
          "enable_realtime_import": {
            "type": "boolean",
            "description": "Defaults to true per schema."
          },
          "rounding_adjustment_account_id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed ledger account (`lac_…`)."
          },
          "last_imported_date": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_import_attempt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1AccountMapping": {
        "type": "object",
        "required": ["id", "integration_type", "realm_id", "external_ledger_account_id"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed mapping ID (`glm_<24-hex>`).",
            "example": "glm_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "nullable": true,
            "example": "org_abc123"
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
          },
          "realm_id": {
            "type": "string"
          },
          "internal_ledger_account_id": {
            "type": "string",
            "nullable": true,
            "description": "Entendre-side mapping join key. Compare this prefixed ledger account ID (`lac_…`) exactly with a ledger-account row's `id`; do not use the mapping row's own `id`, `external_ledger_account_id`, or account name."
          },
          "external_ledger_account_id": {
            "type": "string",
            "description": "Opaque account ID issued by the GL provider and returned by `GET /gl-integration/external-accounts`; never an internal Entendre `ExternalLedger` pointer."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1AccountMappingPair": {
        "type": "object",
        "required": ["internal_ledger_account_id", "external_ledger_account_id"],
        "properties": {
          "internal_ledger_account_id": {
            "type": "string",
            "description": "Entendre ledger-account ID (`lac_…`) from `GET /ledger-accounts`."
          },
          "external_ledger_account_id": {
            "type": "string",
            "description": "Provider-issued account ID from `GET /gl-integration/external-accounts`; never pass the mapping row's `glm_…` id or an internal Entendre `ExternalLedger` pointer."
          }
        }
      },
      "V1EntityMapping": {
        "type": "object",
        "required": ["id", "integration_type", "realm_id", "external_legal_entity_id"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed mapping ID (`lem_<24-hex>`).",
            "example": "lem_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "nullable": true,
            "example": "org_abc123"
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
          },
          "realm_id": {
            "type": "string"
          },
          "internal_legal_entity_id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed legal entity (`le_…`)."
          },
          "external_legal_entity_id": {
            "type": "string",
            "description": "Provider-issued entity/subsidiary ID from `GET /gl-integration/external-entities`; never an internal Entendre `ExternalLegalEntities` pointer."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1EntityMappingPair": {
        "type": "object",
        "required": ["internal_legal_entity_id", "external_legal_entity_id"],
        "properties": {
          "internal_legal_entity_id": {
            "type": "string",
            "description": "Entendre legal-entity ID (`le_…`)."
          },
          "external_legal_entity_id": {
            "type": "string",
            "description": "Provider-issued entity/subsidiary ID from `GET /gl-integration/external-entities`; never pass the mapping row's `lem_…` id or an internal Entendre `ExternalLegalEntities` pointer."
          }
        }
      },
      "V1ExternalAccount": {
        "type": "object",
        "required": ["external_id", "name"],
        "description": "External account from the GL provider (no Entendre id; only external_id is meaningful).",
        "properties": {
          "external_id": {
            "type": "string",
            "description": "Opaque ID from the GL provider."
          },
          "name": {
            "type": "string"
          },
          "account_number": {
            "type": "string",
            "nullable": true
          },
          "parent_account": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "account_type": {
            "type": "string",
            "nullable": true,
            "description": "e.g., Bank, Expense, Credit Card."
          },
          "classification": {
            "type": "string",
            "nullable": true
          },
          "integration_type": {
            "type": "string",
            "nullable": true
          },
          "eliminate": {
            "type": "boolean"
          },
          "realm_id": {
            "type": "string"
          },
          "account_sub_type": {
            "type": "string",
            "nullable": true
          },
          "ledger_account_sequence": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1ExternalEntity": {
        "type": "object",
        "required": ["external_id", "entity_name"],
        "properties": {
          "external_id": {
            "type": "string",
            "description": "Opaque ID from the GL provider."
          },
          "entity_name": {
            "type": "string"
          },
          "address": {
            "type": "string",
            "nullable": true
          },
          "currency": {
            "type": "string",
            "nullable": true
          },
          "integration_type": {
            "type": "string",
            "nullable": true
          },
          "realm_id": {
            "type": "string"
          },
          "entity_type": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1SyncHistory": {
        "type": "object",
        "required": ["id", "integration_type"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed sync history ID (`sh_<24-hex>`).",
            "example": "sh_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "nullable": true,
            "example": "org_abc123"
          },
          "accounting_period_id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed period (`ap_…`)."
          },
          "created_by": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed user (`usr_…`). Null when system-initiated."
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
          },
          "job_status": {
            "type": "string",
            "nullable": true,
            "enum": [null, "STARTED", "IN_PROGRESS", "COMPLETED", "JOB_FAILED", "CANCELED", "HANGED"],
            "description": "Null on legacy pre-2026-02 rows where status lived on the joined JobHistory doc."
          },
          "synced_count": {
            "type": "integer",
            "nullable": true
          },
          "failed_count": {
            "type": "integer",
            "nullable": true
          },
          "total_count": {
            "type": "integer",
            "nullable": true
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1SyncEntry": {
        "type": "object",
        "required": ["id", "journal_entry_id", "external_journal_entry_id", "integration_type", "sync_history_id"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed sync entry ID (`se_<24-hex>`).",
            "example": "se_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "nullable": true,
            "example": "org_abc123"
          },
          "accounting_period_id": {
            "type": "string",
            "description": "Prefixed period (`ap_…`)."
          },
          "journal_entry_id": {
            "type": "string",
            "description": "Prefixed JE (`je_…`)."
          },
          "external_journal_entry_id": {
            "type": "string",
            "description": "Opaque ID from the GL provider."
          },
          "external_type": {
            "type": "string",
            "nullable": true,
            "description": "QBO transaction type the JE landed as: JournalEntry, Purchase, Deposit, etc."
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry"]
          },
          "sync_history_id": {
            "type": "string",
            "description": "Prefixed parent run (`sh_…`)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1SyncAttempt": {
        "type": "object",
        "required": ["id", "journal_entry_id", "integration_type", "sync_history_id", "sync_status"],
        "description": "A FAILED GL-sync attempt (returned by `GET /gl-sync/entries?status=failed`). Carries the failure reason a success record never has.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed sync-attempt ID (`jesa_<24-hex>`).",
            "example": "jesa_507f1f77bcf86cd799439011"
          },
          "organization_id": {
            "type": "string",
            "nullable": true,
            "example": "org_abc123"
          },
          "accounting_period_id": {
            "type": "string",
            "description": "Prefixed period (`ap_…`)."
          },
          "journal_entry_id": {
            "type": "string",
            "description": "Prefixed JE (`je_…`) that failed to sync."
          },
          "integration_type": {
            "type": "string",
            "enum": ["quickbooks", "xero", "netsuite", "dualentry", "campfire"]
          },
          "sync_history_id": {
            "type": "string",
            "description": "Prefixed parent run (`sh_…`)."
          },
          "sync_status": {
            "type": "string",
            "description": "Sync outcome for this attempt (FAILED for rows returned here)."
          },
          "error_type": {
            "type": "string",
            "nullable": true,
            "description": "Categorized failure reason (e.g. UNKNOWN, VALIDATION, DUPLICATE_DETECTED, MAPPING_MISSING, AUTHENTICATION)."
          },
          "error_details": {
            "type": "string",
            "nullable": true,
            "description": "Free-text detail from the provider / sync engine."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1DuplicateMatch": {
        "type": "object",
        "required": ["external_id", "external_type", "amount", "date", "match_confidence", "match_reasons"],
        "properties": {
          "external_id": {
            "type": "string",
            "description": "Opaque ID of the matched provider transaction."
          },
          "external_type": {
            "type": "string",
            "description": "Provider transaction type (Purchase, Deposit, JournalEntry, …)."
          },
          "amount": {
            "type": "number"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "vendor": {
            "type": "string",
            "nullable": true
          },
          "account": {
            "type": "string",
            "nullable": true
          },
          "memo": {
            "type": "string",
            "nullable": true
          },
          "match_confidence": {
            "type": "string",
            "enum": ["HIGH", "MEDIUM", "LOW"]
          },
          "match_reasons": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "e.g., [\"exact_amount\", \"same_date\", \"same_vendor\"]."
          }
        }
      },
      "V1DuplicateScanResult": {
        "type": "object",
        "required": ["journal_entry_id", "accounting_date", "amount", "has_duplicate", "duplicates"],
        "properties": {
          "journal_entry_id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed JE (`je_…`)."
          },
          "journal_sequence_number": {
            "type": "string",
            "nullable": true,
            "description": "e.g., JE-42."
          },
          "accounting_date": {
            "type": "string",
            "format": "date"
          },
          "amount": {
            "type": "number"
          },
          "vendor_name": {
            "type": "string",
            "nullable": true
          },
          "has_duplicate": {
            "type": "boolean"
          },
          "duplicates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/V1DuplicateMatch"
            }
          },
          "sync_record": {
            "type": "object",
            "nullable": true,
            "properties": {
              "external_journal_entry_id": {
                "type": "string"
              },
              "external_type": {
                "type": "string"
              },
              "synced_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      },
      "V1XeroTrackingCategory": {
        "type": "object",
        "required": ["external_id", "name"],
        "properties": {
          "external_id": {
            "type": "string",
            "description": "Xero TrackingCategoryID (uuid)."
          },
          "name": {
            "type": "string"
          },
          "option": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "V1XeroTrackingCategoryMapping": {
        "type": "object",
        "required": ["entendre_tag_key", "xero_tracking_category_name", "position"],
        "description": "Mapping is identified by (entendre_tag_key, position) within a single config — there is no public ID.",
        "properties": {
          "entendre_tag_key": {
            "type": "string",
            "enum": ["Customer", "Supplier", "System", "ID", "Bank Account", "Cost Center", "Class", "Staff", "Product"]
          },
          "xero_tracking_category_name": {
            "type": "string"
          },
          "position": {
            "type": "integer",
            "enum": [1, 2]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1XeroConfig": {
        "type": "object",
        "required": ["tracking_category_mapping"],
        "properties": {
          "tracking_category_mapping": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/V1XeroTrackingCategoryMapping"
            },
            "maxItems": 2
          }
        }
      },
      "V1APCloseSettings": {
        "type": "object",
        "required": ["legal_entity_id", "close_date", "timezone", "retry", "advanced_settings"],
        "description": "Auto-close schedule configuration for one legal entity. The cron job uses `close_date` (a relative-date enum) and `timezone` to decide when to fire.",
        "properties": {
          "id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed settings ID (`apcs_…`)."
          },
          "organization_id": {
            "type": "string",
            "nullable": true,
            "example": "org_abc123"
          },
          "legal_entity_id": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed legal entity (`le_…`)."
          },
          "close_date": {
            "type": "string",
            "enum": [
              "last_day_of_month",
              "3_days_after_last_day_of_month",
              "5_days_after_last_day_of_month",
              "10_days_after_last_day_of_month"
            ],
            "description": "Relative date the auto-close cron fires."
          },
          "timezone": {
            "type": "string",
            "description": "IANA timezone (e.g., `America/New_York`). Defaults to UTC."
          },
          "closed_by": {
            "type": "string",
            "nullable": true,
            "description": "Prefixed user (`usr_…`) who configured it."
          },
          "retry": {
            "type": "integer",
            "description": "Number of failed auto-close retries since last success."
          },
          "advanced_settings": {
            "type": "object",
            "properties": {
              "sync_gl": {
                "type": "boolean",
                "description": "Whether to sync to GL after auto-close."
              },
              "run_revaluation": {
                "type": "boolean",
                "description": "Whether to run revaluation after auto-close."
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V1APLite": {
        "type": "object",
        "required": ["id", "status", "start_date", "end_date"],
        "description": "Minimal accounting-period projection used for fast UI pickers.",
        "properties": {
          "id": {
            "type": "string",
            "example": "ap_111"
          },
          "legal_entity_id": {
            "type": "string",
            "nullable": true,
            "example": "le_456"
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": ["open", "soft_closed", "closed"]
          },
          "start_date": {
            "type": "string",
            "format": "date-time"
          },
          "end_date": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "V1FinancialInsightMeta": {
        "type": "object",
        "required": ["source", "granularity", "start_date", "end_date", "currency"],
        "properties": {
          "source": {
            "type": "string",
            "enum": ["gl", "transactions"]
          },
          "granularity": {
            "type": "string",
            "enum": ["daily", "weekly", "monthly"]
          },
          "start_date": {
            "type": "string",
            "format": "date-time"
          },
          "end_date": {
            "type": "string",
            "format": "date-time"
          },
          "currency": {
            "type": "string",
            "example": "USD"
          },
          "granularity_note": {
            "type": "string",
            "nullable": true
          },
          "period_snapped_note": {
            "type": "string",
            "nullable": true,
            "description": "Present only for source=gl when the requested window is not a whole calendar month: GL is monthly period-resolution, so the totals cover the entire containing accounting period(s), not just the requested sub-month dates. Use source=transactions for sub-month/day-level detail."
          },
          "account_ids_ignored": {
            "type": "boolean",
            "nullable": true
          },
          "account_ids_ignored_reason": {
            "type": "string",
            "nullable": true
          },
          "basis": {
            "type": "string",
            "enum": ["cash", "accrual"],
            "description": "Accounting basis of the figures: cash (source=transactions, settled cash movements) or accrual (source=gl, the posted double-entry ledger)."
          },
          "cash_basis_caveat": {
            "type": "string",
            "nullable": true,
            "description": "Present only for cash-basis (source=transactions): warns that expenses exclude unpaid AP, accruals, non-cash journal entries, and unclassified outflows, so profit can be overstated. Use source=gl for the accrual P&L."
          }
        }
      },
      "V1FinancialInsightSeriesPoint": {
        "type": "object",
        "required": ["period_start", "period_end", "amount"],
        "description": "One bucket of a single-amount financial-insight time series (used by revenue `time_series` and burn-rate `trend`). Monetary amounts are decimal strings.",
        "properties": {
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "period_end": {
            "type": "string",
            "format": "date-time"
          },
          "amount": {
            "type": "string",
            "description": "Decimal-string amount for the bucket (revenue: income; burn-rate: expenses)."
          }
        }
      },
      "V1FinancialInsightCategoryAmount": {
        "type": "object",
        "required": ["category", "amount"],
        "description": "A named category total. `amount` is a decimal string.",
        "properties": {
          "category": {
            "type": "string"
          },
          "amount": {
            "type": "string"
          }
        }
      },
      "V1FinancialInsightSpendingEntry": {
        "type": "object",
        "required": ["key", "amount"],
        "description": "A keyed spending total (by source type or classification). `amount` is a decimal string.",
        "properties": {
          "key": {
            "type": "string"
          },
          "amount": {
            "type": "string"
          }
        }
      },
      "V1SpamToken": {
        "type": "object",
        "required": [
          "id",
          "organization_id",
          "asset_type",
          "raw_contract_address",
          "chain",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed `spt_` id."
          },
          "organization_id": {
            "type": ["string", "null"],
            "description": "Prefixed `org_` id."
          },
          "asset_type": {
            "type": "string"
          },
          "raw_contract_address": {
            "type": "string"
          },
          "chain": {
            "type": "string"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "V1WhitelistToken": {
        "type": "object",
        "required": [
          "id",
          "organization_id",
          "chain",
          "asset_type",
          "address",
          "created_by",
          "updated_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed `wlt_` id."
          },
          "organization_id": {
            "type": ["string", "null"],
            "description": "Prefixed `org_` id."
          },
          "chain": {
            "type": "string"
          },
          "asset_type": {
            "type": "string"
          },
          "address": {
            "type": "string"
          },
          "created_by": {
            "type": ["string", "null"],
            "description": "Prefixed `usr_` id, or null."
          },
          "updated_by": {
            "type": ["string", "null"],
            "description": "Prefixed `usr_` id, or null."
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "V1TableGroupMemberRef": {
        "type": "object",
        "required": ["type", "id"],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "wallet",
              "raincard",
              "niural",
              "plaid_account",
              "exchange_source",
              "ramp_card",
              "ramp_bank_account"
            ],
            "description": "Member's source type. `manual_bank_account` is feed-only and cannot be a member."
          },
          "id": {
            "type": "string",
            "description": "The member's own prefixed id (e.g. `wal_...` for `type: wallet`)."
          }
        }
      },
      "V1JournalEntryAttachment": {
        "type": "object",
        "required": [
          "id",
          "journal_entry_id",
          "filename",
          "content_type",
          "signed_url",
          "signed_url_expires_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed `jea_` id."
          },
          "journal_entry_id": {
            "type": ["string", "null"],
            "description": "Prefixed `je_` id."
          },
          "filename": {
            "type": ["string", "null"]
          },
          "content_type": {
            "type": ["string", "null"]
          },
          "signed_url": {
            "type": ["string", "null"],
            "description": "Freshly generated signed read URL, valid for a limited time — re-fetch rather than caching long-term."
          },
          "signed_url_expires_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "V1WebhookDelivery": {
        "type": "object",
        "required": [
          "id",
          "webhook_subscription_id",
          "organization_id",
          "event_type",
          "event_id",
          "status",
          "attempt_count",
          "last_attempt_at",
          "last_response_status_code",
          "next_attempt_at",
          "last_error_message",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed `wkd_` id."
          },
          "webhook_subscription_id": {
            "type": "string",
            "description": "Prefixed `whk_` id."
          },
          "organization_id": {
            "type": "string"
          },
          "event_type": {
            "type": "string"
          },
          "event_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["pending", "in_progress", "succeeded", "failed", "failed_terminal"]
          },
          "attempt_count": {
            "type": "integer"
          },
          "last_attempt_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "last_response_status_code": {
            "type": ["integer", "null"]
          },
          "last_response_body": {
            "type": ["string", "null"]
          },
          "next_attempt_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "last_error_message": {
            "type": ["string", "null"]
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          }
        }
      },
      "V1CashApplicationMatch": {
        "type": "object",
        "description": "Deprecated singular-path cash-application match record (`cam_` id space). See `V1CashApplication` for the resource-shaped successor.",
        "required": [
          "id",
          "organization_id",
          "status",
          "match_type",
          "deposit_amount",
          "deposit_memo",
          "deposit_date",
          "allocations",
          "journal_entry_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": ["string", "null"],
            "description": "Prefixed `cam_` id."
          },
          "organization_id": {
            "type": ["string", "null"]
          },
          "status": {
            "type": ["string", "null"],
            "enum": [
              "pending",
              "proposed",
              "applied_pending_stripe",
              "completed",
              "rejected",
              "expired",
              "exception",
              null
            ]
          },
          "match_type": {
            "type": ["string", "null"],
            "enum": ["exact", "multi_invoice", "tolerance", null]
          },
          "deposit_amount": {
            "type": "string",
            "description": "Decimal string."
          },
          "deposit_memo": {
            "type": ["string", "null"]
          },
          "deposit_date": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "allocations": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "invoice_id": {
                  "type": ["string", "null"]
                },
                "invoice_number": {
                  "type": ["string", "null"]
                },
                "amount": {
                  "type": "string"
                },
                "customer_name": {
                  "type": ["string", "null"]
                }
              }
            }
          },
          "applied_by": {
            "type": ["string", "null"]
          },
          "journal_entry_id": {
            "type": ["string", "null"],
            "description": "Prefixed `je_` id."
          },
          "stripe_marked_paid_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "confirmed_by": {
            "type": ["string", "null"]
          },
          "confirmed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "error_message": {
            "type": ["string", "null"]
          }
        }
      },
      "StripePayout": {
        "type": "object",
        "required": ["id", "stripe_payout_id", "amount", "currency", "status", "total_fee", "allocations", "fees"],
        "properties": {
          "id": { "type": ["string", "null"] },
          "organization_id": { "type": ["string", "null"] },
          "connection_id": { "type": ["string", "null"] },
          "legal_entity_id": { "type": ["string", "null"] },
          "transaction_id": { "type": ["string", "null"] },
          "stripe_payout_id": { "type": "string" },
          "status": { "type": "string" },
          "amount": { "type": "string" },
          "currency": { "type": "string" },
          "arrival_date": { "type": ["string", "null"], "format": "date-time" },
          "created_at_provider": { "type": "string", "format": "date-time" },
          "method": { "type": ["string", "null"] },
          "statement_descriptor": { "type": ["string", "null"] },
          "total_fee": { "type": "string" },
          "allocations": { "type": "array", "items": { "$ref": "#/components/schemas/StripePayoutAllocation" } },
          "fees": { "type": "array", "items": { "$ref": "#/components/schemas/StripePayoutFee" } }
        }
      },
      "StripePayoutAllocation": {
        "type": "object",
        "properties": {
          "charge_id": { "type": "string" },
          "stripe_invoice_id": { "type": "string" },
          "amount_paid": { "type": "string" },
          "currency": { "type": "string" }
        }
      },
      "StripePayoutFee": {
        "type": "object",
        "properties": {
          "balance_transaction_id": { "type": "string" },
          "amount": { "type": "string" },
          "currency": { "type": "string" },
          "description": { "type": ["string", "null"] },
          "created_at_provider": { "type": "string", "format": "date-time" }
        }
      },
      "StripePayoutListResponse": {
        "type": "object",
        "required": ["data", "has_more", "next_cursor"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/StripePayout" } },
          "has_more": { "type": "boolean" },
          "next_cursor": { "type": ["string", "null"] }
        }
      },
      "StripePayoutSingleResponse": {
        "type": "object",
        "required": ["data"],
        "properties": { "data": { "$ref": "#/components/schemas/StripePayout" } }
      },
      "StripeInvoiceListResponse": {
        "type": "object",
        "required": ["data", "has_more", "next_cursor"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/V1StripeARInvoice" } },
          "has_more": { "type": "boolean" },
          "next_cursor": { "type": ["string", "null"] }
        }
      },
      "V1CashApplicationCompiledRule": {
        "type": "object",
        "description": "A compiled cash-application routing rule sourced from the caller's own org-memory (Blob storage), forwarded per-request rather than read from a backend cache. Read via `readCompiledCashApplicationRules` in the copilot; third-party callers supply the identical shape.",
        "required": ["ruleId", "action", "match"],
        "additionalProperties": false,
        "properties": {
          "ruleId": {
            "type": "string",
            "minLength": 1
          },
          "action": {
            "type": "string",
            "enum": ["route_to_account", "require_human_review"]
          },
          "match": {
            "type": "object",
            "required": ["kind", "operator", "value", "normalizedValue"],
            "additionalProperties": false,
            "properties": {
              "kind": {
                "type": "string",
                "enum": ["invoice_line"]
              },
              "operator": {
                "type": "string",
                "enum": ["exact", "contains"]
              },
              "value": {
                "type": "string",
                "minLength": 1
              },
              "normalizedValue": {
                "type": "string",
                "minLength": 1
              }
            }
          },
          "target": {
            "type": ["object", "null"],
            "required": ["accountName"],
            "additionalProperties": false,
            "properties": {
              "accountName": {
                "type": "string",
                "minLength": 1
              },
              "ledgerAccountId": {
                "type": "string",
                "minLength": 1
              }
            }
          }
        }
      }
    }
  }
}
