{"openapi":"3.1.0","info":{"title":"Transaction Analyzer API","description":"Transaction Analyzer API — REST surface for integrations.\n\n## Authorization model\n\nData is scoped to a **workspace**. Every user has exactly one auto-provisioned **personal workspace**; users may also belong to **shared workspaces** as `owner`, `editor`, or `viewer`.\n\nThe same routes accept two kinds of bearer:\n- **Firebase ID tokens** — first-party traffic (the SPA). These may target any workspace the caller belongs to via the `X-Workspace-Id` request header; the header defaults to the caller's personal workspace when absent. Reads require membership, writes require `editor`+, and management requires `owner`.\n- **OAuth 2.0 access tokens** issued by this server at `/oauth/token` (PKCE only, no client secret) — third-party traffic (the MCP server, future integrations). OAuth tokens carry **no workspace selection**: the `X-Workspace-Id` header is ignored and every request operates on the caller's **personal workspace** only.\n\nEither kind verifies to the same Firebase `uid`; routes do not differentiate by which produced it. Anonymous Firebase users are rejected.","version":"1.0.0"},"components":{"securitySchemes":{"oauth2":{"type":"oauth2","description":"OAuth 2.0 authorization-code flow with PKCE. Tokens are user-scoped.","flows":{"authorizationCode":{"authorizationUrl":"https://api.taz.randomfact.com/oauth/authorize","tokenUrl":"https://api.taz.randomfact.com/oauth/token","scopes":{}}}}},"schemas":{}},"paths":{"/v1/workspaces":{"get":{"operationId":"listWorkspaces","summary":"List the workspaces you belong to.","description":"Returns one entry per membership (the reverse index) with the workspace name, your role, and whether it is your personal workspace.","responses":{"200":{"description":"Default Response"}}},"post":{"operationId":"createWorkspace","summary":"Create a new workspace (you become its owner).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120}},"required":["name"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/workspaces/{wsId}":{"put":{"operationId":"renameWorkspace","summary":"Rename a workspace (owner only).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120}},"required":["name"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"deleteWorkspace","summary":"Delete a workspace and all its data (owner only).","description":"Refuses to delete your personal workspace. Recursively removes the workspace's transactions, rules, categories, members, and invites.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/workspaces/{wsId}/members":{"get":{"operationId":"listWorkspaceMembers","summary":"List a workspace's members and their roles.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/workspaces/{wsId}/members/{uid}":{"put":{"operationId":"changeWorkspaceMemberRole","summary":"Change a member's role (owner only).","description":"Cannot demote the last owner. Roles: owner | editor | viewer.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"role":{"type":"string","enum":["owner","editor","viewer"]}},"required":["role"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true},{"schema":{"type":"string","minLength":1},"in":"path","name":"uid","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"removeWorkspaceMember","summary":"Remove a member from a workspace, or leave it yourself.","description":"Removing SOMEONE ELSE requires owner. Removing yourself (leaving) requires only membership — otherwise an editor or viewer, who can never be owner, would have no way out of a workspace. Cannot remove the last owner, and your personal workspace cannot be left.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true},{"schema":{"type":"string","minLength":1},"in":"path","name":"uid","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/workspaces/{wsId}/invites":{"post":{"operationId":"createInvite","summary":"Invite a person to a workspace by email + role (owner only).","description":"Rejects a duplicate with 409: one pending invitation per email per workspace, and none for someone who is already a member.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","maxLength":254,"format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"role":{"type":"string","enum":["editor","viewer"]}},"required":["email","role"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true}],"responses":{"200":{"description":"Default Response"}}},"get":{"operationId":"listOutstandingInvites","summary":"List a workspace's outstanding invitations (owner only).","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"wsId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/invites/pending":{"get":{"operationId":"listPendingInvites","summary":"List invitations addressed to your verified email.","description":"Resolved server-side against the verified Firebase token email. Callers without a verified address — OAuth tokens (no email claim) and unverified sign-ins alike — get an empty list.","responses":{"200":{"description":"Default Response"}}}},"/v1/invites/{id}/accept":{"post":{"operationId":"acceptInvite","summary":"Accept an invitation addressed to your verified email.","description":"Refused with 403 unless the caller's token carries an email address that Firebase Auth reports as verified.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/invites/{id}":{"delete":{"operationId":"revokeInvite","summary":"Revoke a pending invitation (owner only).","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/categories":{"get":{"operationId":"listCategories","summary":"List all categories.","description":"Returns every category for the authenticated user. Pass `expand=rules` to nest each category's rules.","parameters":[{"schema":{"type":"string","enum":["rules"]},"in":"query","name":"expand","required":false}],"responses":{"200":{"description":"Default Response"}}},"post":{"operationId":"createCategory","summary":"Create a new category.","description":"Creates a single category from a `{ name, kind? }` body. Bulk import moved to the single-file bundle on `POST /transactions/import` (TAZ-79).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1},"kind":{"type":"string","enum":["spend","income","transfer","saving"]}},"required":["name"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/categories/{name}":{"put":{"operationId":"updateCategory","summary":"Rename a category and/or change its `kind`.","description":"Pass `name` to rename (rules referencing it are migrated; the rename preserves `kind`). Pass `kind` to change the category's economic role (spend|income|transfer|saving). Either or both may be supplied. A `kind`-only change does not touch transactions.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1},"kind":{"type":"string","enum":["spend","income","transfer","saving"]}}}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"name","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"deleteCategory","summary":"Delete a category. Referencing rules and transaction overrides must be transferred to another category.","description":"Deletes `/workspaces/{wsId}/categories/{name}`. If one or more rules reference the category OR one or more transactions carry a manual override pointing at it, the request body MUST include `{ transferTo: <other-category> }` — referencing rules' `category` field is rewritten to the target in the same Firestore batch, override transactions are rewritten in place to the target (keeping their override status), and the rule-edit auto-backfill is kicked off. If nothing references the category, the body is optional and ignored.","requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"transferTo":{"type":"string","minLength":1}}},{"type":"null"}]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"name","required":true}],"responses":{"200":{"description":"Default Response"}}},"get":{"operationId":"getCategory","summary":"Get one category by name.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"name","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/categories/migrate-kinds":{"post":{"operationId":"migrateCategoryKinds","summary":"One-time: assign a `kind` to every category that lacks one.","description":"Idempotent. Sets each category's `kind` from a name map (Income→income, Savings*/Investments*→saving, Transfers/Credit Card Payment→transfer, else spend). Categories that already carry a valid kind are left unchanged.","responses":{"200":{"description":"Default Response"}}}},"/v1/rules":{"get":{"operationId":"listRules","summary":"List rules. Optional `category` filter.","parameters":[{"schema":{"type":"string"},"in":"query","name":"category","required":false}],"responses":{"200":{"description":"Default Response"}}},"post":{"operationId":"createRule","summary":"Create a rule. Triggers a backfill across existing transactions.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1},"description":{"type":["string","null"]},"notDescription":{"type":["string","null"]},"source":{"type":["string","null"]},"debit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"credit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"balance":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"tags":{"type":"array","items":{"type":"string"}},"precedence":{"type":"integer","minimum":1,"maximum":9007199254740991},"target":{"type":"string","enum":["description","merchant"]}},"required":["category","precedence"]}}}},"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"bulkDeleteRules","summary":"Delete multiple rules in one request. Triggers a single backfill.","description":"Up to 100 ids per request. Deletes the rules in chunked Firestore batches, then fires exactly ONE backfill pass + ONE dataset-version bump (vs N for per-rule deletes) — the single-source-of-truth backfill model (see CLAUDE.md “Rule-edit propagation”). Unknown ids are silently skipped. The SPA chunks selections larger than the cap.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"minItems":1,"maxItems":100,"type":"array","items":{"type":"string","minLength":1}}},"required":["ids"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/rules/{id}":{"get":{"operationId":"getRule","summary":"Get one rule by id.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}},"put":{"operationId":"updateRule","summary":"Update a rule by id. Triggers a backfill.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1},"description":{"type":["string","null"]},"notDescription":{"type":["string","null"]},"source":{"type":["string","null"]},"debit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"credit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"balance":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"tags":{"type":"array","items":{"type":"string"}},"precedence":{"type":"integer","minimum":1,"maximum":9007199254740991},"target":{"type":"string","enum":["description","merchant"]}},"required":["category","precedence"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"deleteRule","summary":"Delete a rule by id. Triggers a backfill.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/rules/{id}/matches":{"get":{"operationId":"getRuleMatches","summary":"Transactions currently matched by a rule.","description":"Reads the materialized `categorization.rules` slot (matched + tied), capped at 100. Faster than `test-rule` because it does two indexed point queries instead of a full scan.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/rules/reapply":{"post":{"operationId":"reapplyRules","summary":"Manually trigger a rule re-application across every transaction.","responses":{"200":{"description":"Default Response"}}}},"/v1/transactions":{"get":{"operationId":"listTransactions","summary":"List transactions matching a query DSL. Cursor-paginated.","description":"Returns `{ rows, nextCursor, truncated? }`. `truncated: true` means the in-memory residual scan hit its cap. By default only active rows are returned; pass `deleted=true` for the soft-deleted (tombstoned) rows, or `deleted=all` for both.","parameters":[{"schema":{"type":"string"},"in":"query","name":"query","required":false},{"schema":{"type":"string"},"in":"query","name":"cursor","required":false},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"in":"query","name":"limit","required":false},{"schema":{"type":"string","enum":["true","only","all"]},"in":"query","name":"deleted","required":false}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"bulkDeleteTransactions","summary":"Soft-delete (tombstone) a batch of transactions by id.","description":"Up to 100 ids per request. Tombstones each existing row (`deleted: true`); missing ids are silently skipped. Returns the number actually tombstoned, those rows in `rows`, and `previousVersion`/`version`. Bumps `_meta/dataset.version` exactly once when at least one row changed; a pass that changed nothing reports no version at all. Undo via `POST /transactions/undelete`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"minItems":1,"maxItems":100,"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/summary":{"get":{"operationId":"summarizeTransactions","summary":"Aggregate transactions matching the query DSL.","description":"Returns counts and sums grouped by status, source, and category. Computed in-process from the prefilter+residual result (capped at TRANSACTIONS_FILTER_CAP).","parameters":[{"schema":{"type":"string"},"in":"query","name":"query","required":false}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/version":{"get":{"operationId":"getDatasetVersion","summary":"Read the user's transaction-cache dataset version.","description":"The SPA cache calls this once at app load to decide whether the cached snapshot (stored in IndexedDB, tagged with the version it was streamed at) is still current. A mismatch with the cached version triggers a re-stream via `GET /api/transactions/stream`.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"integer","minimum":0,"maximum":9007199254740991}},"required":["version"],"additionalProperties":false}}}}}}},"/v1/transactions/stream":{"get":{"operationId":"streamTransactions","summary":"Stream transactions for the authenticated user as NDJSON — the whole corpus, or just what changed.","description":"Owner-scoped. No DSL. Each row is one JSON object per line (Content-Type: application/x-ndjson). Pass `since=<epoch-millis>` for a DELTA stream (TAZ-130): only rows whose server-stamped `updatedAt` is at or after that watermark, ordered oldest-first. Omit it for the full dataset. Rows carry `updatedAt` as epoch millis (absent on rows written before delta sync existed). The `X-Dataset-Version` response header carries the version the stream snapshot represents, and `X-Max-Updated-At` the watermark to send as the next `since`; if the version bumps mid-stream, the trailing line is `{\"__streamRestart\": true, \"version\": N}` so the client can re-issue with the SAME `since`. The bound is inclusive on purpose — rows committed in one batch share an `updatedAt`, and an exclusive bound could split a batch across two deltas. Uses the Admin SDK `.stream()` API to keep server memory bounded.","parameters":[{"schema":{"type":"integer","minimum":0,"maximum":253402300799999},"in":"query","name":"since","required":false}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/export":{"get":{"operationId":"exportTransactions","summary":"Export the account as a single JSON bundle for re-import: categories, rules, and transactions in one file.","description":"Returns `{ categories, rules, transactions }` (TAZ-79) — the payload `POST /transactions/import` accepts, so one file fully reconstructs an account. Categories and rules are always complete; the optional `query` DSL filters the transactions section only.","parameters":[{"schema":{"type":"string"},"in":"query","name":"query","required":false}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/{id}":{"put":{"operationId":"overrideTransaction","summary":"Override a transaction's category and/or tags.","description":"Pass `null` to clear an override; omit a field to leave it unchanged. Reruns rule resolution and returns the updated transaction state, plus `previousVersion`/`version` — the `_meta/dataset` versions this write moved between, which let a caller adopt the change without re-reading the dataset.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":["string","null"]},"tags":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}}}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"deleteTransaction","summary":"Soft-delete (tombstone) a single transaction.","description":"Sets `deleted: true` in place rather than removing the document, so the deletion survives a re-upload/re-import of the same statement (the deterministic id is tombstoned) and can be undone via `POST /transactions/:id/undelete`. The row drops out of every active view. Returns the tombstoned row in `rows` plus `previousVersion`/`version` — the `_meta/dataset` versions this write moved between — so a caller holding `previousVersion` can adopt the change instead of re-reading the dataset. 404 if the id does not exist.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/undelete":{"post":{"operationId":"bulkUndeleteTransactions","summary":"Restore a batch of soft-deleted transactions.","description":"Up to 100 ids per request. Clears the tombstone on each soft-deleted row and re-applies the current rule set (rows that aren't deleted are ignored). Returns the number actually restored, those rows in `rows` (carrying the re-applied categorization, not the pre-delete one), and `previousVersion`/`version`. Bumps `_meta/dataset.version` once when at least one row changed; a pass that changed nothing reports no version at all.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"minItems":1,"maxItems":100,"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/{id}/undelete":{"post":{"operationId":"undeleteTransaction","summary":"Restore a single soft-deleted transaction.","description":"Clears the `deleted` tombstone and re-applies the current rule set so the row returns to the active views with up-to-date categorization. Returns the restored row in `rows` with its re-applied categorization, plus `previousVersion`/`version`. No-op (returns `undeleted: 0`, no rows, no version) if the row exists but isn't deleted.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/dismiss-dupe":{"post":{"operationId":"dismissTransactionDuplicateCluster","summary":"Record that a set of transactions are NOT duplicates of each other.","description":"Pass the full cluster's ids in a single call. The server writes the bidirectional `dismissedDupeOf` cross-product in one Firestore batch (N writes, not N²), so the next scan skips every pair within the cluster.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"minItems":2,"maxItems":500,"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/upload":{"post":{"operationId":"uploadTransactions","summary":"Upload a CSV or PDF statement (multipart, ≤ 5 MiB).","description":"Archives the bytes + creates a `queued` transactionFiles doc, then defers parse/ingest/categorize to a Cloud Task and returns 202 immediately. Track progress via the file doc. Not exposed as an MCP tool (multipart body).","responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/files/{fileId}":{"patch":{"operationId":"reportUnsupportedFile","summary":"Tell us what kind of file you uploaded when the parser didn't recognize it.","description":"Valid only when the file's `status` is `unsupported`. Writes user-supplied source/account-type/notes onto the transactionFiles doc and mirrors the report to an admin-only review collection.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"source":{"type":"string","minLength":1,"maxLength":120},"accountType":{"type":"string","enum":["bank","creditCard","other"]},"notes":{"type":"string","maxLength":2000}},"required":["source","accountType"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"fileId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/files/{fileId}/download":{"get":{"operationId":"downloadTransactionFile","summary":"Get a short-lived signed URL for an uploaded file's bytes.","description":"Any member of the workspace may re-download an original upload. Returns a 5-minute signed URL — Storage itself is REST-mediated (no direct client access).","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"fileId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/import":{"post":{"operationId":"importTransactions","summary":"Import an account bundle: categories, rules, and transactions from one JSON object.","description":"Accepts exactly what `GET /transactions/export` produces — `{ categories, rules, transactions }` (TAZ-79); every section is optional. Categories and rules are imported first (idempotent: existing categories are preserved, functionally identical rules are skipped), then transactions are written with the merged rule set applied. When new rules were created, the rule-edit backfill re-checks pre-existing transactions.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"categories":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1},"kind":{"type":"string","enum":["spend","income","transfer","saving"]}},"required":["name"]}},"rules":{"type":"array","items":{"type":"object","properties":{"category":{"type":"string","minLength":1},"description":{"type":["string","null"]},"notDescription":{"type":["string","null"]},"source":{"type":["string","null"]},"debit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"credit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"balance":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"tags":{"type":"array","items":{"type":"string"}},"precedence":{"type":"integer","minimum":1,"maximum":9007199254740991},"target":{"type":"string","enum":["description","merchant"]},"id":{"type":"string"}},"required":["category","precedence"]}},"transactions":{"type":"array","items":{"type":"object","properties":{"transactionDate":{"type":"integer","minimum":19000101,"maximum":99991231,"description":"Date as YYYYMMDD integer"},"description":{"type":"string"},"debit":{"type":["number","null"]},"credit":{"type":["number","null"]},"balance":{"type":["number","null"]},"source":{"type":["string","null"]},"raw":{"type":["string","null"]},"categoryOverride":{"type":["string","null"]},"tagsOverride":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"isDeleted":{"type":"boolean"}},"required":["transactionDate","description"]}}}}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/test-rule":{"post":{"operationId":"testRule","summary":"Preview which transactions a candidate rule would match.","description":"Returns up to 100 matches; the rule is not saved. Useful for inspecting a candidate before creating it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1},"description":{"type":["string","null"]},"notDescription":{"type":["string","null"]},"source":{"type":["string","null"]},"debit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"credit":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"balance":{"anyOf":[{"type":"object","properties":{"min":{"type":"number"},"max":{"type":"number"},"value":{"type":"number"}}},{"type":"null"}]},"tags":{"type":"array","items":{"type":"string"}},"precedence":{"type":"integer","minimum":1,"maximum":9007199254740991},"target":{"type":"string","enum":["description","merchant"]}},"required":["category","precedence"]}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/suggest-rules":{"post":{"operationId":"suggestRules","summary":"Cold-start: classify uncovered merchants and apply rules.","description":"Kicks off the background cold-start classifier: groups the user's uncovered transactions by canonical merchant key, resolves each via the deterministic structural map, then the built-in merchant table (a few hundred well-known brands shipped with the app — no LLM call), then the LLM (into an existing category, or a clearly-fitting new one), and applies the confident results as merchant-target rules — coining a category when needed. Runs on the suggest-rules worker and DRAINS across multiple passes (one pass only classifies up to the per-call ceiling), so it is not bounded by this request's timeout. Returns 202 immediately; watch `_suggestRulesState/current` for `running` and `lastSummary`. Coalesces if a run is already active.","responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/files/{fileId}/profile/propose":{"post":{"operationId":"proposeCsvProfile","summary":"Propose a column mapping for an unrecognized CSV.","description":"Reads the file's already-archived bytes and returns a candidate mapping, its validation against every row, and preview rows. The candidate is a proposal only — nothing is saved and no transactions are written until `PUT .../profile`. A null candidate means the proposer is disabled or failed; the manual editor still works.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"fileId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/files/{fileId}/profile/preview":{"post":{"operationId":"previewCsvProfile","summary":"Validate an edited mapping against the file, saving nothing.","description":"Backs the confirmation UI's live preview: every previewed row is produced by applying this mapping to the file's own bytes.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"profile":{"type":"object","properties":{"id":{"type":"string","minLength":1},"label":{"type":"string","minLength":1,"maxLength":120},"source":{"type":"string","minLength":1,"maxLength":120},"match":{"anyOf":[{"type":"object","properties":{"fingerprint":{"type":"string","minLength":1}},"required":["fingerprint"],"additionalProperties":false},{"type":"object","properties":{"columns":{"type":"integer","minimum":1,"maximum":256},"cells":{"maxItems":8,"type":"array","items":{"type":"object","properties":{"column":{"type":"integer","minimum":0,"maximum":255},"pattern":{"type":"string","minLength":1,"maxLength":200}},"required":["column","pattern"],"additionalProperties":false}}},"required":["columns"],"additionalProperties":false}]},"header":{"type":"string","maxLength":2000},"hasHeader":{"type":"boolean"},"delimiter":{"type":"string","enum":["comma","semicolon"]},"date":{"type":"object","properties":{"column":{"type":"integer","minimum":0,"maximum":255},"format":{"type":"string","enum":["MM/DD/YYYY","M/D/YYYY","DD/MM/YYYY","YYYY-MM-DD","YYYY/MM/DD","YYYYMMDD","DD.MM.YYYY","DD-MM-YYYY","MM/DD/YY","DD/MM/YY","MMM DD, YYYY"]}},"required":["column","format"],"additionalProperties":false},"description":{"type":"integer","minimum":0,"maximum":255},"amount":{"anyOf":[{"type":"object","properties":{"signed":{"type":"integer","minimum":0,"maximum":255},"negativeIs":{"type":"string","enum":["debit","credit"]}},"required":["signed"],"additionalProperties":false},{"type":"object","properties":{"debit":{"type":"integer","minimum":0,"maximum":255},"credit":{"type":"integer","minimum":0,"maximum":255}},"required":["debit","credit"],"additionalProperties":false},{"type":"object","properties":{"amount":{"type":"integer","minimum":0,"maximum":255},"type":{"type":"integer","minimum":0,"maximum":255},"creditValues":{"minItems":1,"type":"array","items":{"type":"string"}},"caseSensitive":{"type":"boolean"}},"required":["amount","type","creditValues"],"additionalProperties":false}]},"balance":{"anyOf":[{"type":"object","properties":{"column":{"type":"integer","minimum":0,"maximum":255}},"required":["column"],"additionalProperties":false},{"type":"object","properties":{"constant":{"type":"number"}},"required":["constant"],"additionalProperties":false}]},"sourceColumn":{"type":"integer","minimum":0,"maximum":255},"weak":{"type":"boolean"},"verified":{"type":"boolean"},"provenance":{"type":"string","maxLength":500}},"required":["source","match","hasHeader","date","description","amount"],"additionalProperties":false}},"required":["profile"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"fileId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/transactions/files/{fileId}/profile":{"put":{"operationId":"confirmCsvProfile","summary":"Confirm a mapping, save it, and import the file through it.","description":"Re-validates the mapping against the file (the posted profile is untrusted input), stores it under its header fingerprint, and re-ingests the archived bytes — no re-upload. Later files with the same fingerprint then parse with no interaction.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"profile":{"type":"object","properties":{"id":{"type":"string","minLength":1},"label":{"type":"string","minLength":1,"maxLength":120},"source":{"type":"string","minLength":1,"maxLength":120},"match":{"anyOf":[{"type":"object","properties":{"fingerprint":{"type":"string","minLength":1}},"required":["fingerprint"],"additionalProperties":false},{"type":"object","properties":{"columns":{"type":"integer","minimum":1,"maximum":256},"cells":{"maxItems":8,"type":"array","items":{"type":"object","properties":{"column":{"type":"integer","minimum":0,"maximum":255},"pattern":{"type":"string","minLength":1,"maxLength":200}},"required":["column","pattern"],"additionalProperties":false}}},"required":["columns"],"additionalProperties":false}]},"header":{"type":"string","maxLength":2000},"hasHeader":{"type":"boolean"},"delimiter":{"type":"string","enum":["comma","semicolon"]},"date":{"type":"object","properties":{"column":{"type":"integer","minimum":0,"maximum":255},"format":{"type":"string","enum":["MM/DD/YYYY","M/D/YYYY","DD/MM/YYYY","YYYY-MM-DD","YYYY/MM/DD","YYYYMMDD","DD.MM.YYYY","DD-MM-YYYY","MM/DD/YY","DD/MM/YY","MMM DD, YYYY"]}},"required":["column","format"],"additionalProperties":false},"description":{"type":"integer","minimum":0,"maximum":255},"amount":{"anyOf":[{"type":"object","properties":{"signed":{"type":"integer","minimum":0,"maximum":255},"negativeIs":{"type":"string","enum":["debit","credit"]}},"required":["signed"],"additionalProperties":false},{"type":"object","properties":{"debit":{"type":"integer","minimum":0,"maximum":255},"credit":{"type":"integer","minimum":0,"maximum":255}},"required":["debit","credit"],"additionalProperties":false},{"type":"object","properties":{"amount":{"type":"integer","minimum":0,"maximum":255},"type":{"type":"integer","minimum":0,"maximum":255},"creditValues":{"minItems":1,"type":"array","items":{"type":"string"}},"caseSensitive":{"type":"boolean"}},"required":["amount","type","creditValues"],"additionalProperties":false}]},"balance":{"anyOf":[{"type":"object","properties":{"column":{"type":"integer","minimum":0,"maximum":255}},"required":["column"],"additionalProperties":false},{"type":"object","properties":{"constant":{"type":"number"}},"required":["constant"],"additionalProperties":false}]},"sourceColumn":{"type":"integer","minimum":0,"maximum":255},"weak":{"type":"boolean"},"verified":{"type":"boolean"},"provenance":{"type":"string","maxLength":500}},"required":["source","match","hasHeader","date","description","amount"],"additionalProperties":false}},"required":["profile"]}}}},"parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"fileId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/csv-profiles":{"get":{"operationId":"listCsvProfiles","summary":"List the workspace's confirmed CSV format profiles.","responses":{"200":{"description":"Default Response"}}}},"/v1/csv-profiles/{profileId}":{"delete":{"operationId":"deleteCsvProfile","summary":"Forget a saved CSV format profile.","description":"Transactions already imported through the profile are left intact — this forgets how to READ the format, not what was read. The next upload of that format re-enters the mapping flow.","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"profileId","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/duplicates/scan":{"post":{"operationId":"scanDuplicates","summary":"Run a full-corpus duplicate-candidate scan.","description":"Runs synchronously: awaits the scan to completion (or `status: \"error\"` on failure) before returning. Progress is observable mid-scan via a snapshot listener on `/workspaces/{wsId}/_meta/duplicateReport`, so the SPA renders the bar from Firestore — the HTTP response just marks the worker as done.\n\nWhy not fire-and-forget? Cloud Run throttles container CPU to ~1% the moment a response is sent, so a detached background promise crawls. Holding the request open keeps CPU at the full allocation until the scan completes.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"reportPath":{"type":"string"},"status":{"type":"string","enum":["done"]}},"required":["reportPath","status"],"additionalProperties":false}}}}}}},"/v1/reports/uncategorized":{"get":{"operationId":"getUncategorizedReport","summary":"Transactions with no effective category, with referenced rules.","description":"Returns rows whose `effectiveCategory` is empty (no override, no matching rule, or a conflict). The `reason` field distinguishes no-match from conflict; `ruleIds` lists the conflicting rules when relevant.","responses":{"200":{"description":"Default Response"}}}},"/v1/admin/unsupported-uploads":{"get":{"operationId":"listUnsupportedUploads","summary":"List all user-reported unsupported uploads (admin only).","responses":{"200":{"description":"Default Response"}}}},"/v1/admin/unsupported-uploads/{id}/download":{"get":{"operationId":"downloadUnsupportedUpload","summary":"Get a short-lived signed URL for the original uploaded bytes (admin only).","parameters":[{"schema":{"type":"string","minLength":1},"in":"path","name":"id","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/v1/admin/csv-profiles":{"get":{"operationId":"listConfirmedCsvProfiles","summary":"List every workspace-confirmed CSV profile (admin only).","description":"A confirmed profile is a mapping a real user verified against a real file, which is exactly what the built-in corpus wants. Returned in the shape the corpus stores, so a good one can be transcribed into `shared/src/parsers/csv/builtins/` as-is.","responses":{"200":{"description":"Default Response"}}}}},"servers":[{"url":"https://api.taz.randomfact.com"}],"security":[{"oauth2":[]}]}