{ "openapi": "3.0.3", "info": { "title": "Expenses Service", "version": "1.1.0", "description": "OpenAPI-first service for tracking expenses and cashflow." }, "security": [ { "bearerAuth": [] } ], "paths": { "/users": { "get": { "security": [], "operationId": "list_users", "summary": "List users", "responses": { "200": { "description": "List of users", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } } } }, "post": { "operationId": "create_user", "summary": "Create a new user", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "responses": { "201": { "description": "User created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } } } } }, "/users/me": { "get": { "operationId": "get_current_user", "x-profile": true, "summary": "My Profile", "responses": { "200": { "description": "Current user's profile (auto-created if new)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } } } }, "put": { "operationId": "update_current_user", "x-profile": true, "summary": "Update My Profile", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "responses": { "200": { "description": "Profile updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } } } } }, "/users/{id}": { "get": { "security": [], "operationId": "get_user", "summary": "Get user by ID", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "User found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "404": { "description": "User not found" } } } }, "/entities": { "get": { "security": [], "operationId": "list_entities", "summary": "List entities", "parameters": [ { "name": "offset", "in": "query", "description": "Number of records to skip", "schema": { "type": "integer", "minimum": 0, "default": 0 } }, { "name": "limit", "in": "query", "description": "Maximum number of records to return", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 10 } } ], "responses": { "200": { "description": "List of entities", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Entity" } } } } } } }, "post": { "operationId": "create_entity", "summary": "Create a new entity", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Entity" } } } }, "responses": { "201": { "description": "Entity created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Entity" } } } } } } }, "/entities/{id}": { "get": { "security": [], "operationId": "get_entity", "summary": "Get entity by ID", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Entity found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Entity" } } } }, "404": { "description": "Entity not found" } } }, "put": { "operationId": "update_entity", "summary": "Update an entity", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Entity" } } } }, "responses": { "200": { "description": "Entity updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Entity" } } } }, "404": { "description": "Entity not found" } } }, "delete": { "operationId": "delete_entity", "summary": "Delete an entity", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Entity deleted" }, "404": { "description": "Entity not found" } } } }, "/entities/{id}/logo": { "post": { "summary": "Upload entity logo", "operationId": "upload_entity_logo", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "file": { "type": "string", "format": "binary" } } } } } }, "responses": { "200": { "description": "Logo uploaded", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "url": { "type": "string" }, "logo": { "type": "string" }, "status": { "type": "string" } } } } } }, "404": { "description": "Entity not found" } } } }, "/expenses": { "post": { "operationId": "create_expense", "summary": "Create a new expense or income entry", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Expense" } } } }, "responses": { "201": { "description": "Expense created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Expense" } } } } } }, "get": { "security": [], "operationId": "list_expenses", "summary": "List expenses", "parameters": [ { "name": "offset", "in": "query", "description": "Number of records to skip", "schema": { "type": "integer", "minimum": 0, "default": 0 } }, { "name": "limit", "in": "query", "description": "Maximum number of records to return", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 10 } }, { "name": "start_date", "in": "query", "schema": { "type": "string", "format": "date-time" } }, { "name": "end_date", "in": "query", "schema": { "type": "string", "format": "date-time" } } ], "responses": { "200": { "description": "Paginated list of expenses", "content": { "application/json": { "schema": { "type": "object", "required": [ "items", "total" ], "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Expense" } }, "total": { "type": "integer" } } } } } } } } }, "/expenses/{id}": { "get": { "security": [], "operationId": "get_expense", "summary": "Get expense by ID", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Expense found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Expense" } } } }, "404": { "description": "Expense not found" } } }, "put": { "operationId": "update_expense", "summary": "Update Expense or Income Entry", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Expense" } } } }, "responses": { "201": { "description": "Expense created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Expense" } } } } } }, "delete": { "operationId": "delete_expense", "summary": "Delete an expense or income entry", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Expense deleted" }, "404": { "description": "Expense not found" } } } }, "/accounts": { "post": { "operationId": "create_account", "summary": "Create an account", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } } }, "responses": { "201": { "description": "Account created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } } } } }, "get": { "security": [], "operationId": "list_accounts", "summary": "List accounts", "responses": { "200": { "description": "List of accounts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Account" } } } } } } } }, "/accounts/{id}": { "get": { "security": [], "operationId": "get_account", "summary": "Get account by ID", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Account found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } } }, "404": { "description": "Account not found" } } }, "put": { "operationId": "update_account", "summary": "Update an account", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } } }, "responses": { "200": { "description": "Account updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } } }, "404": { "description": "Account not found" } } }, "delete": { "operationId": "delete_account", "summary": "Delete an account", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Account deleted" }, "404": { "description": "Account not found" } } } }, "/tags": { "post": { "operationId": "create_tag", "summary": "Create a tag", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } } }, "responses": { "201": { "description": "Tag created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } } } } }, "get": { "security": [], "operationId": "list_tags", "summary": "List tags", "responses": { "200": { "description": "List of tags", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } } } } } } }, "/tags/{id}": { "get": { "security": [], "operationId": "get_tag", "summary": "Get tag by ID", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Tag found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } } }, "404": { "description": "Tag not found" } } }, "put": { "operationId": "update_tag", "summary": "Update a tag", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } } }, "responses": { "200": { "description": "Tag updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } } }, "404": { "description": "Tag not found" } } }, "delete": { "operationId": "delete_tag", "summary": "Delete a tag", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Tag deleted" }, "404": { "description": "Tag not found" } } } }, "/fetch-requests": { "get": { "operationId": "list_fetch_requests", "summary": "List fetch requests", "parameters": [ { "name": "status", "in": "query", "description": "Filter by status", "schema": { "type": "string", "enum": [ "pending", "processing", "paused", "raw_expenses_done", "enriched_done", "completed", "failed" ] } }, { "name": "account", "in": "query", "description": "Filter by account", "schema": { "type": "string" } }, { "name": "source_type", "in": "query", "description": "Filter by source type", "schema": { "type": "string", "enum": [ "file", "email", "pdf", "xlsx", "csv" ] } } ], "responses": { "200": { "description": "List of fetch requests", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FetchRequest" } } } } } } }, "post": { "operationId": "create_fetch_request", "summary": "Create a new fetch request", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "responses": { "201": { "description": "Fetch request created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "409": { "description": "Duplicate fetch request (same fingerprint already exists)" } } } }, "/fetch-requests/{id}": { "get": { "operationId": "get_fetch_request", "summary": "Get fetch request by ID", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Fetch request found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "404": { "description": "Fetch request not found" } } }, "put": { "operationId": "update_fetch_request_put", "summary": "Update a fetch request", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "responses": { "200": { "description": "Fetch request updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "404": { "description": "Fetch request not found" } } }, "patch": { "operationId": "update_fetch_request", "summary": "Update a fetch request (e.g. retry after failure)", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "responses": { "200": { "description": "Fetch request updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FetchRequest" } } } }, "404": { "description": "Fetch request not found" } } }, "delete": { "operationId": "delete_fetch_request", "summary": "Delete a fetch request and all its associated data", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Fetch request deleted" }, "404": { "description": "Fetch request not found" } } } }, "/fetch-requests/{id}/ambiguities": { "get": { "operationId": "list_ambiguities", "summary": "List ambiguities for a fetch request", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "List of pending ambiguities", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Ambiguity" } } } } } } } }, "/ambiguities/{id}/resolve": { "post": { "operationId": "resolve_ambiguity", "summary": "Resolve an ambiguity with a chosen amount/balance", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "chosen" ], "properties": { "chosen": { "type": "object", "required": [ "amount", "balance" ], "properties": { "amount": { "type": "number" }, "balance": { "type": "number" } } } } } } } }, "responses": { "200": { "description": "Ambiguity resolved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ambiguity" } } } }, "400": { "description": "Missing chosen field" }, "404": { "description": "Ambiguity not found" } } } }, "/fetch-requests/{id}/events": { "get": { "operationId": "stream_fetch_events", "x-sse": true, "summary": "Stream pipeline progress events via SSE", "description": "Opens a Server-Sent Events connection that receives real-time pipeline progress updates for the given fetch request.\n\n\u2500\u2500 Pipeline Lifecycle \u2500\u2500\n1. Client \u2192 POST /fetch-requests (create, status=pending) 2. Worker \u2192 picks up pending request (status\u2192processing) 3. Worker \u2192 emits `raw_lines/started`\n \u2193 loads source (PDF/email), OCRs pages, extracts raw text lines\n Worker \u2192 emits `raw_lines/completed`\n \u2193 splits lines into transaction blocks\n Worker \u2192 emits `txn_blocks/completed`\n \u2193 parses each block into a transaction dict (one `txn_dicts/progress` per dict)\n Worker \u2192 emits `txn_dicts/completed`, then `raw_expenses/completed`\n (status\u2192raw_expenses_done)\n4. Worker \u2192 emits `enrich_entity/started`\n \u2193 enriches each raw expense with payee via LLM (one `enrich_entity/progress` per item)\n Worker \u2192 emits `enrich_entity/completed`\n5. Worker \u2192 emits `enrich_tags/started`\n \u2193 enriches each raw expense with tags via LLM (one `enrich_tags/progress` per item)\n Worker \u2192 emits `enrich_tags/completed` (status\u2192enriched_done)\n6. Worker \u2192 emits `save_expenses/started`\n \u2193 creates Expense objects in DB (one `save_expenses/progress` per item)\n Worker \u2192 emits `save_expenses/completed` (status\u2192completed)\n\nIf ambiguity is detected during step 3 (txn_dicts extraction):\n Worker \u2192 emits `paused` (status\u2192paused)\n \u2193\n UI fetches GET /fetch-requests/{id}/ambiguities\n \u2193\n UI shows choices to user\n \u2193\n User picks \u2192 POST /ambiguities/{id}/resolve\n \u2193\n Server checks all ambiguities resolved \u2192 status\u2192pending\n \u2193\n Worker picks up, resumes from txn_dicts step,\n applies pre-resolved choice, continues normally.\n\n\n\u2500\u2500 SSE Protocol \u2500\u2500\nConnect: GET /fetch-requests/{id}/events Encoding: UTF-8 text/event-stream Delimiter: Double newline (\\n\\n)\nEach message:\n data: {\"step\":\"...\",\"status\":\"...\",\"message\":{...}}\n \\n\n\nNo event field \u2014 only `data`. The `data:` line is always a single JSON object (ProgressEvent). The `message` field inside is a ProgressMessage object \u2014 see the schema for its typed fields.\nThe connection stays open; the server sends messages as pipeline progress unfolds. The client should reconnect on connection loss with exponential backoff.\n\n\u2500\u2500 Events Reference \u2500\u2500\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 step \u2502 status \u2502 meaning \u2502 message fields \u2502 \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 \u2502 raw_lines \u2502 started \u2502 Fetching source + OCR + \u2502 \u2014 \u2502 \u2502 \u2502 \u2502 extracting raw text lines \u2502 \u2502 \u2502 raw_lines \u2502 completed \u2502 Raw lines extracted via OCR \u2502 `{\"lines\": int}` \u2502 \u2502 txn_blocks \u2502 completed \u2502 Lines split into txn blocks \u2502 `{\"blocks\": int}` \u2502 \u2502 txn_dicts \u2502 progress \u2502 Single transaction parsed \u2502 `{\"count\": int}` \u2502 \u2502 txn_dicts \u2502 completed \u2502 All transactions parsed \u2502 `{\"count\": int}` \u2502 \u2502 resume_extract \u2502 started \u2502 Resuming from saved partial \u2502 \u2014 \u2502 \u2502 extract \u2502 skipped \u2502 All steps already cached \u2502 \u2014 \u2502 \u2502 paused \u2502 paused \u2502 Ambiguity \u2014 needs user input \u2502 `{\"raw_ocr_line\": str}` \u2502 \u2502 raw_expenses \u2502 completed \u2502 All raw expenses saved to DB \u2502 `{\"count\": int,` \u2502 \u2502 \u2502 \u2502 \u2502 `\"unit\": str}` \u2502 \u2502 enrich_entity \u2502 started \u2502 Enriching raw expenses with \u2502 \u2014 \u2502 \u2502 \u2502 \u2502 payee via LLM \u2502 \u2502 \u2502 enrich_entity \u2502 progress \u2502 Single raw expense enriched \u2502 `{\"count\": int}` \u2502 \u2502 enrich_entity \u2502 completed \u2502 All raw expenses enriched \u2502 `{\"count\": int}` \u2502 \u2502 enrich_tags \u2502 started \u2502 Enriching raw expenses with \u2502 \u2014 \u2502 \u2502 \u2502 \u2502 tags via LLM \u2502 \u2502 \u2502 enrich_tags \u2502 progress \u2502 Single raw expense tagged \u2502 `{\"count\": int}` \u2502 \u2502 enrich_tags \u2502 completed \u2502 All raw expenses tagged \u2502 `{\"count\": int}` \u2502 \u2502 save_expenses \u2502 started \u2502 Creating Expense objects in DB \u2502 \u2014 \u2502 \u2502 save_expenses \u2502 progress \u2502 Single expense saved \u2502 `{\"count\": int}` \u2502 \u2502 save_expenses \u2502 completed \u2502 All expenses persisted \u2502 `{\"count\": int}` \u2502 \u2502 pipeline \u2502 failed \u2502 Pipeline crashed with error \u2502 `{\"error\": str}` \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u2500\u2500 Live Counts via SSE vs. Persisted Count on Page Reload \u2500\u2500\ntxn_dicts are parsed one at a time. Each parsed dict fires a `txn_dicts/progress` event with the running `count` in the message payload.\nThe UI should use these SSE events to show a live progress count (e.g. \"Validated 47 / ~300 transactions\"). The same pattern applies to `enrich_entity`, `enrich_tags`, and `save_expenses` \u2014 each fires one `progress` event per item processed. `raw_lines` is a timed step (source fetch + OCR + line extraction) with only `started` and `completed` \u2014 no intermediate progress.\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 Scenario \u2502 How to get the count \u2502 \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 \u2502 SSE connected, live \u2502 Parse `progress` events for the \u2502 \u2502 \u2502 relevant step (`txn_dicts`, `enrich_entity`, \u2502 \u2502 \u2502 or `save_expenses`). \u2502 \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 \u2502 Page reload during pause \u2502 Poll GET /fetch-requests/{id} \u2502 \u2502 (no SSE, stale txn_dicts) \u2502 and read `source.txn_dicts_count`. \u2502 \u2502 \u2502 This field is persisted in the DB \u2502 \u2502 \u2502 every time a `PauseForResolution` is \u2502 \u2502 \u2502 raised, reflecting the exact number \u2502 \u2502 \u2502 of successfully validated dicts. \u2502 \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 \u2502 SSE reconnect after loss \u2502 On reconnect, use latest \u2502 \u2502 \u2502 `txn_dicts/progress` count from the \u2502 \u2502 \u2502 new stream; catch up from there. \u2502 \u2502 \u2502 The DB count (`source.txn_dicts_count`) \u2502 \u2502 \u2502 is a fallback if the stream restarts \u2502 \u2502 \u2502 from scratch. \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\nOnly `txn_dicts_count` is persisted in the DB. `raw_lines` and `txn_blocks` either complete fully or fail and restart \u2014 they never pause mid-stream, so no persistence is needed for them.\n\n\u2500\u2500 Status Lifecycle (FetchRequest.status) \u2500\u2500\npending \u2500\u2500\u2192 processing \u2500\u2500\u2192 raw_expenses_done \u2500\u2500\u2192 enriched_done \u2500\u2500\u2192 completed\n \u2502 \u2502\n \u2193 \u2193\n paused \u2500\u2500\u2192 pending (user resolves) failed\n\nPoll GET /fetch-requests/{id} to observe status transitions alongside SSE events.\n", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "SSE event stream. Each message's `data` field is a JSON object conforming to ProgressEvent. The connection remains open; terminate on client side when done.\n", "content": { "text/event-stream": { "schema": { "$ref": "#/components/schemas/ProgressEvent" } } } } } } }, "/uploads": { "get": { "operationId": "list_uploads", "summary": "List uploaded files", "responses": { "200": { "description": "List of uploaded files", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UploadResult" } } } } } } }, "post": { "operationId": "upload_file", "summary": "Upload a file", "description": "Upload a file as raw binary. The filename is extracted from the Content-Disposition header.", "requestBody": { "required": true, "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } } }, "parameters": [ { "name": "content-disposition", "in": "header", "required": true, "schema": { "type": "string" }, "description": "Must contain filename=\"...\"" } ], "responses": { "201": { "description": "File uploaded successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UploadResult" } } } }, "400": { "description": "Missing required headers" } } } }, "/uploads/{filepath:path}": { "get": { "security": [], "operationId": "get_uploaded_file", "summary": "Get an uploaded file", "parameters": [ { "name": "filepath", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "File found", "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } } }, "404": { "description": "File not found" } } } }, "/reports": { "get": { "security": [], "operationId": "list_reports", "summary": "List report snapshots", "description": "List report snapshot metadata (name, query, generated_at). The heavy cube payload lives in Redis and is exposed via GET /reports/{id}.", "parameters": [ { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "default": 0 } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "default": 100 } } ], "responses": { "200": { "description": "List of report snapshots", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ReportSnapshot" } } } } } } }, "post": { "security": [], "operationId": "create_report", "summary": "Generate a report snapshot", "description": "Generate a snapshot of the filtered expense set, precomputing a cube of per-granularity series grouped by payee and tag (with tag hierarchy roll-up) and split into inflows/outflows. One request creates one snapshot; the same definition (identical name + query) returns the existing snapshot instead of duplicating it. Payee/tag/granularity are sliced at read time via GET /reports/{id}, not at build time.\n", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReportQuery" } } } }, "responses": { "200": { "description": "Snapshot generated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReportSnapshot" } } } }, "400": { "description": "Invalid query \u2014 bad granularity/group dimension/flow, invalid or inverted dates, or invalid amounts" } } } }, "/reports/{id}": { "get": { "security": [], "operationId": "get_report", "summary": "Get a sliced report view", "description": "Fetch a snapshot's precomputed cube and slice it by flow, granularity, period ids, payee, and tag. Omitting a granularity returns the snapshot's precomputed granularities; omitting period_ids returns the whole series; omitting payee/tag returns the global (ungrouped) series. Tags match exact precomputed hierarchy paths. Passing payee=* or tags=* expands to every value present in the cube, returning one bucket per value (full breakdown).\n", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "granularity", "in": "query", "required": false, "schema": { "type": "array", "items": { "type": "string", "enum": [ "weekly", "monthly", "quarterly" ] } } }, { "name": "period_ids", "in": "query", "required": false, "schema": { "type": "array", "items": { "type": "string" } } }, { "name": "flow", "in": "query", "required": false, "schema": { "type": "string", "enum": [ "inflows", "outflows" ], "default": "outflows" } }, { "name": "payee", "in": "query", "required": false, "description": "Payee names to slice by. The literal \"*\" expands to all payees in the cube (one bucket per payee).\n", "schema": { "type": "array", "items": { "type": "string" } } }, { "name": "tags", "in": "query", "required": false, "description": "Exact precomputed tag paths to slice by. The literal \"*\" expands to all tag paths in the cube (one bucket per path).\n", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "description": "Sliced report view", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Report" } } } }, "400": { "description": "Invalid granularity, flow, or period id" }, "404": { "description": "Report not found" } } }, "delete": { "security": [], "operationId": "delete_report", "summary": "Delete a report snapshot", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Report deleted" }, "404": { "description": "Report not found" } } } } }, "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT", "x-server-url": "{AUTH_SERVER}", "x-login-path": "/login", "x-register-path": "/register", "x-logout-path": "/logout", "x-me-path": "/me", "x-introspect-path": "/introspect" } }, "schemas": { "PublicUser": { "type": "object", "required": [ "id", "username" ], "properties": { "id": { "type": "string" }, "username": { "type": "string" }, "email": { "type": "string", "format": "email" } } }, "Payee": { "type": "object", "x-display-format": "{name}", "required": [ "type", "name" ], "properties": { "type": { "type": "string", "enum": [ "merchant", "person", "transfer", "other" ] }, "name": { "type": "string" } } }, "Account": { "type": "object", "x-resource": "accounts", "x-primary-key": "id", "x-display-format": "{name} \u2014 XX{number}", "x-list-columns": [ "name", "number", "type", "currency", "owner" ], "required": [ "id", "name", "number", "type", "currency", "owner" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "name": { "type": "string", "x-order": 1, "x-label": "Name", "x-filterable": true, "x-sortable": true }, "number": { "type": "string", "x-order": 2, "x-label": "Account Number", "x-filterable": true }, "type": { "type": "string", "enum": [ "cash", "bank", "credit_card", "wallet", "other" ], "x-order": 3, "x-label": "Type", "x-filterable": true }, "currency": { "type": "string", "enum": [ "INR", "USD", "EUR", "GBP", "AED", "SGD" ], "x-order": 4, "x-label": "Currency", "x-filterable": true }, "owner": { "$ref": "#/components/schemas/User", "x-order": 1, "x-label": "Owner", "x-display-format": "{username}", "x-fk": { "resource": "users" } }, "created_at": { "type": "string", "format": "date-time", "x-order": 5, "x-label": "Created", "x-hidden": { "form": true, "list": true } }, "updated_at": { "type": "string", "format": "date-time", "x-order": 6, "x-label": "Updated", "x-hidden": { "form": true, "list": true } } } }, "Tag": { "type": "object", "x-resource": "tags", "x-primary-key": "id", "x-display-format": "{icon} {name}", "x-list-columns": [ "name", "icon" ], "required": [ "id", "name", "icon" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "name": { "type": "string", "x-order": 1, "x-label": "Name", "x-filterable": true, "x-sortable": true }, "icon": { "type": "string", "x-order": 2, "x-label": "Icon", "x-filterable": true }, "parent_id": { "type": "string", "nullable": true, "x-order": 3, "x-label": "Parent Tag", "x-fk": { "resource": "tags" }, "x-filterable": true }, "created_at": { "type": "string", "format": "date-time", "x-order": 4, "x-label": "Created", "x-hidden": { "form": true, "list": true } }, "updated_at": { "type": "string", "format": "date-time", "x-order": 5, "x-label": "Updated", "x-hidden": { "form": true, "list": true } } } }, "User": { "type": "object", "x-resource": "users", "x-primary-key": "id", "x-display-format": "{name}", "x-list-columns": [ "name", "username", "email" ], "required": [ "name", "username", "email" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "name": { "type": "string", "x-order": 1, "x-label": "Name", "x-filterable": true, "x-sortable": true }, "username": { "type": "string", "x-order": 2, "x-label": "Username", "x-filterable": true }, "email": { "type": "string", "x-order": 3, "x-label": "Email", "x-filterable": true }, "created_at": { "type": "string", "format": "date-time", "x-order": 4, "x-label": "Created", "x-hidden": { "form": true, "list": true } }, "updated_at": { "type": "string", "format": "date-time", "x-order": 5, "x-label": "Updated", "x-hidden": { "form": true, "list": true } } } }, "Entity": { "type": "object", "x-resource": "entities", "x-primary-key": "id", "x-display-format": "{name}", "x-list-columns": [ "logo", "name", "type" ], "required": [ "id", "name", "type" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "name": { "type": "string", "x-order": 1, "x-label": "Name", "x-filterable": true, "x-sortable": true }, "type": { "type": "string", "enum": [ "merchant", "person", "transfer", "other" ], "x-order": 2, "x-label": "Type", "x-filterable": true }, "logo": { "type": "string", "format": "binary", "x-ui-type": "image", "x-upload-url": "/entities/{id}/logo", "x-order": 3, "x-label": "Logo", "x-description": "Entity logo image" }, "created_at": { "type": "string", "format": "date-time", "x-order": 3, "x-label": "Created", "x-hidden": { "form": true, "list": true } }, "updated_at": { "type": "string", "format": "date-time", "x-order": 4, "x-label": "Updated", "x-hidden": { "form": true, "list": true } } } }, "Expense": { "type": "object", "x-resource": "expenses", "x-primary-key": "id", "x-display-format": "{entity} \u2014 \u20b9{amount}", "x-list-columns": [ "occurred_at", "entity", "amount", "account", "tags" ], "required": [ "id", "entity", "amount", "account", "occurred_at", "created_at" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "entity": { "$ref": "#/components/schemas/Entity", "x-order": 1, "x-label": "Entity", "x-fk": { "resource": "entities", "prefetch": true }, "x-filter-type": "autocomplete", "x-filterable": true }, "amount": { "type": "number", "format": "float", "x-order": 2, "x-label": "Amount", "x-filter-type": "number-range", "x-filterable": true, "x-sortable": true }, "account": { "$ref": "#/components/schemas/Account", "x-order": 3, "x-label": "Account", "x-fk": { "resource": "accounts", "prefetch": true }, "x-filter-type": "multiselect", "x-filterable": true }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" }, "x-order": 4, "x-label": "Tags", "x-fk": { "resource": "tags", "prefetch": true }, "x-filter-type": "autocomplete", "x-filterable": true }, "occurred_at": { "type": "string", "format": "date-time", "x-order": 5, "x-label": "Date", "x-filter-type": "date-range", "x-filterable": true, "x-sortable": true }, "created_at": { "type": "string", "format": "date-time", "x-order": 6, "x-label": "Created", "x-hidden": { "form": true, "list": true } }, "updated_at": { "type": "string", "format": "date-time", "x-order": 7, "x-label": "Updated", "x-hidden": { "form": true, "list": true } }, "fetch_request": { "type": "string", "nullable": true, "x-order": 8, "x-label": "Fetch Request", "x-hidden": { "form": true, "list": true }, "x-fk": { "resource": "fetch-requests" } } } }, "SourceBase": { "type": "object", "required": [ "bank" ], "properties": { "bank": { "type": "string", "enum": [ "axis", "icici" ], "x-order": 1, "x-label": "Bank", "x-filterable": true }, "raw_lines": { "type": "array", "items": { "type": "string" }, "x-order": 10, "x-label": "Raw Lines", "x-hidden": { "form": true, "list": true }, "description": "Raw text lines extracted from the source via OCR" }, "txn_blocks": { "type": "object", "x-order": 11, "x-label": "Transaction Blocks", "x-hidden": { "form": true, "list": true }, "description": "Transaction blocks with opening balance; each block is a list of text lines for one transaction" }, "txn_dicts": { "type": "array", "items": { "$ref": "#/components/schemas/RawExpense" }, "x-order": 12, "x-label": "Raw Transactions", "x-hidden": { "form": true }, "description": "Parsed and validated transaction dictionaries" }, "txn_dicts_count": { "type": "integer", "x-order": 13, "x-label": "Transaction Count", "x-hidden": { "form": true }, "description": "Number of transaction dicts successfully validated so far. Persisted in DB so a page reload during a pause still shows progress. Live count arrives via SSE (`txn_dicts / progress` events).\n" }, "enrichment_dicts": { "type": "array", "items": { "$ref": "#/components/schemas/EnrichedExpense" }, "x-order": 14, "x-label": "Enriched Expenses", "x-hidden": { "form": true }, "description": "Enriched expenses with resolved payee and tags" } } }, "Source": { "x-display-format": "{format}", "required": [ "format" ], "oneOf": [ { "$ref": "#/components/schemas/PdfSource" }, { "$ref": "#/components/schemas/XlsxSource" }, { "$ref": "#/components/schemas/CsvSource" }, { "$ref": "#/components/schemas/EmailSource" } ], "discriminator": { "propertyName": "type", "mapping": { "pdf": "PdfSource", "xlsx": "XlsxSource", "csv": "CsvSource", "email": "EmailSource" } } }, "FileSource": { "allOf": [ { "$ref": "#/components/schemas/SourceBase" }, { "type": "object", "x-display-format": "FILE:{format}", "required": [ "path", "type" ], "properties": { "type": { "type": "string", "enum": [ "file" ], "x-order": 0, "x-hidden": { "form": true, "list": true } }, "path": { "type": "string", "x-order": 2, "x-label": "File Path", "x-upload": { "type": "file", "url": "/uploads" }, "x-hidden": { "list": true } } } } ] }, "PdfSource": { "allOf": [ { "$ref": "#/components/schemas/SourceBase" }, { "type": "object", "x-display-format": "PDF:{format}", "required": [ "path", "type" ], "properties": { "type": { "type": "string", "enum": [ "pdf" ], "x-order": 0, "x-hidden": { "form": true, "list": true } }, "path": { "type": "string", "x-order": 2, "x-label": "File Path", "x-upload": { "type": "file", "url": "/uploads", "accept": ".pdf" }, "x-hidden": { "list": true } } } } ] }, "XlsxSource": { "allOf": [ { "$ref": "#/components/schemas/SourceBase" }, { "type": "object", "x-display-format": "XLSX:{format}", "required": [ "path", "type" ], "properties": { "type": { "type": "string", "enum": [ "xlsx" ], "x-order": 0, "x-hidden": { "form": true, "list": true } }, "path": { "type": "string", "x-order": 2, "x-label": "File Path", "x-upload": { "type": "file", "url": "/uploads", "accept": ".xlsx,.xlsm" }, "x-hidden": { "list": true } } } } ] }, "CsvSource": { "allOf": [ { "$ref": "#/components/schemas/SourceBase" }, { "type": "object", "x-display-format": "CSV:{format}", "required": [ "path", "type" ], "properties": { "type": { "type": "string", "enum": [ "csv" ], "x-order": 0, "x-hidden": { "form": true, "list": true } }, "path": { "type": "string", "x-order": 2, "x-label": "File Path", "x-upload": { "type": "file", "url": "/uploads", "accept": ".csv" }, "x-hidden": { "list": true } } } } ] }, "EmailSource": { "allOf": [ { "$ref": "#/components/schemas/SourceBase" }, { "type": "object", "x-display-format": "EMAIL:{format}", "required": [ "type" ], "properties": { "type": { "type": "string", "enum": [ "email" ], "x-order": 0, "x-hidden": { "form": true, "list": true } }, "from_email": { "type": "string", "x-order": 2, "x-label": "From", "x-hidden": { "list": true } }, "subject": { "type": "string", "x-order": 3, "x-label": "Subject", "x-hidden": { "list": true } }, "raw_terms": { "type": "array", "items": { "type": "string" }, "x-order": 4, "x-label": "Search Terms", "x-hidden": { "form": true, "list": true } } } } ] }, "RawExpense": { "type": "object", "required": [ "date", "amount", "balance", "description", "raw_lines", "direction" ], "properties": { "date": { "type": "string", "description": "Transaction date in DD-MM-YYYY format" }, "amount": { "type": "number", "format": "float", "description": "Transaction amount" }, "balance": { "type": "number", "format": "float", "description": "Account balance after transaction" }, "description": { "type": "string", "description": "Complete reconstructed transaction description" }, "raw_lines": { "type": "array", "items": { "type": "string" }, "description": "Raw OCR/text lines that form this transaction" }, "direction": { "type": "string", "enum": [ "DEBIT", "CREDIT" ] } } }, "EnrichedExpense": { "type": "object", "required": [ "raw_expense", "payee", "tags" ], "properties": { "raw_expense": { "$ref": "#/components/schemas/RawExpense" }, "payee": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } } } }, "FetchRequest": { "type": "object", "x-resource": "fetch-requests", "x-primary-key": "id", "x-display-format": "{account} \u2014 {status}", "x-list-columns": [ "status", "account", "fingerprint", "created_at" ], "required": [ "source", "account", "id", "status", "fingerprint", "created_at" ], "properties": { "source": { "$ref": "#/components/schemas/Source", "x-order": 1, "x-label": "Source" }, "account": { "$ref": "#/components/schemas/Account", "x-order": 2, "x-label": "Account", "x-fk": { "resource": "accounts", "prefetch": true } }, "start_date": { "type": "string", "format": "date-time", "x-edit-as": "date", "x-order": 4, "x-label": "Start Date" }, "end_date": { "type": "string", "format": "date-time", "x-edit-as": "date", "x-order": 5, "x-label": "End Date" }, "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "pipeline": { "type": "string", "enum": [ "heuristic", "llm", "llama_parse" ], "default": "heuristic", "x-order": 6, "x-label": "Pipeline", "x-filterable": true }, "trust_fallback": { "type": "string", "enum": [ "amount", "balance" ], "nullable": true, "x-order": 6, "x-label": "Trusted Fallback", "description": "On a broken balance chain, skip OCR-style guesswork and always trust the chosen column: amount derives the running balance, or the stated balance derives the amount." }, "status": { "type": "string", "enum": [ "pending", "processing", "paused", "raw_expenses_done", "enriched_done", "completed", "failed" ], "x-order": 7, "x-label": "Status", "x-filterable": true }, "fingerprint": { "type": "string", "x-order": 7, "x-hidden": { "form": true }, "x-label": "Fingerprint" }, "completed_at": { "type": "string", "format": "date-time", "nullable": true, "x-order": 8, "x-label": "Completed" }, "error_message": { "type": "string", "nullable": true, "x-order": 9, "x-label": "Error" }, "retry_count": { "type": "integer", "default": 0, "x-order": 10, "x-label": "Retries" }, "created_at": { "type": "string", "format": "date-time", "x-order": 11, "x-label": "Created", "x-hidden": { "form": true } }, "updated_at": { "type": "string", "format": "date-time", "x-order": 12, "x-label": "Updated", "x-hidden": { "form": true, "list": true } } } }, "Ambiguity": { "type": "object", "required": [ "id", "fetch_request_id", "line", "ocr_amount", "ocr_balance", "prev_balance", "candidates", "status" ], "properties": { "id": { "type": "string" }, "fetch_request_id": { "type": "string" }, "step_index": { "type": "integer", "default": 2 }, "line": { "type": "string", "description": "The raw OCR line that triggered the ambiguity" }, "ocr_amount": { "type": "number" }, "ocr_balance": { "type": "number" }, "prev_balance": { "type": "number" }, "candidates": { "type": "array", "items": { "type": "object", "properties": { "amount": { "type": "number" }, "balance": { "type": "number" } } } }, "chosen": { "type": "object", "nullable": true, "properties": { "amount": { "type": "number" }, "balance": { "type": "number" } } }, "resolved_at": { "type": "string", "format": "date-time", "nullable": true }, "status": { "type": "string", "enum": [ "pending", "resolved" ] }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } }, "ProgressEvent": { "type": "object", "required": [ "step", "status", "message" ], "description": "A single pipeline progress event delivered via Server-Sent Events. The server sends `data: \\n\\n` where `` is a serialised ProgressEvent object.\nThe `message` field is always a typed ProgressMessage object \u2014 its fields depend on the (step, status) pair. See the Events Reference table in the endpoint description for the exact field-to-event mapping.\nSee the `GET /fetch-requests/{id}/events` endpoint for the full workflow reference and event table.\n", "properties": { "step": { "$ref": "#/components/schemas/ProgressStep" }, "status": { "$ref": "#/components/schemas/ProgressStatus" }, "message": { "$ref": "#/components/schemas/ProgressMessage" } } }, "ProgressMessage": { "type": "object", "description": "Typed payload embedded in every ProgressEvent. Only the fields relevant to the specific (step, status) pair are populated; all other fields are omitted.\nSee the Events Reference table under the `GET /fetch-requests/{id}/events` endpoint for the exact mapping of (step, status) \u2192 message fields.\n", "properties": { "lines": { "type": "integer", "description": "Number of extracted raw text lines. Present on `raw_lines / completed` events.\n" }, "blocks": { "type": "integer", "description": "Number of transaction blocks parsed from raw lines. Present on `txn_blocks / completed` events.\n" }, "count": { "type": "integer", "description": "Running or final count of items. Present on: - `txn_dicts / progress` \u2014 running count of dicts parsed so far - `txn_dicts / completed` \u2014 final count of parsed dicts - `raw_expenses / completed` \u2014 number of raw expenses saved to DB - `enrich_entity / progress` \u2014 running count of raw expenses enriched - `enrich_entity / completed` \u2014 final count of enriched expenses - `enrich_tags / progress` \u2014 running count of raw expenses tagged - `enrich_tags / completed` \u2014 final count of tagged expenses - `save_expenses / progress` \u2014 running count of expenses saved - `save_expenses / completed` \u2014 final count of saved expenses\n" }, "unit": { "type": "string", "description": "Unit label for the count. Currently only used on `raw_expenses / completed` (e.g. `\"raw expenses\"`).\n" }, "raw_ocr_line": { "type": "string", "description": "The exact OCR-extracted text line that triggered an ambiguity. Present only on `paused / paused` events.\n" }, "error": { "type": "string", "description": "Error message describing what went wrong. Present only on `pipeline / failed` events.\n" } } }, "UploadResult": { "type": "object", "x-resource": "uploads", "x-primary-key": "id", "x-display-format": "{original_filename}", "x-list-columns": [ "original_filename", "content_type", "url", "created_at" ], "required": [ "original_filename", "saved_as", "content_type", "url", "absolute_path" ], "properties": { "id": { "type": "string", "x-order": 0, "x-label": "ID" }, "original_filename": { "type": "string", "x-order": 1, "x-label": "Original Filename" }, "saved_as": { "type": "string", "x-order": 2, "x-label": "Saved As" }, "content_type": { "type": "string", "x-order": 3, "x-label": "Content Type" }, "url": { "type": "string", "x-order": 4, "x-label": "URL" }, "absolute_path": { "type": "string", "x-order": 5, "x-label": "Absolute Path" }, "created_at": { "type": "string", "format": "date-time", "x-order": 6, "x-label": "Created" }, "updated_at": { "type": "string", "format": "date-time", "x-order": 7, "x-label": "Updated" } } }, "ReportQuery": { "type": "object", "description": "Definition of a report snapshot. Bounds the data set (dates, accounts, amounts, flow, self-transfer exclusion); payee/tag/granularity/period are sliced at read time via GET /reports/{id}.\n", "x-resource": "reports", "x-primary-key": "id", "properties": { "name": { "type": "string", "x-order": 0, "x-label": "Name", "description": "Optional human label for this snapshot (part of its identity)." }, "granularities": { "type": "array", "items": { "type": "string", "enum": [ "weekly", "monthly", "quarterly" ] }, "default": [ "weekly", "monthly", "quarterly" ], "x-order": 1, "x-label": "Granularities", "description": "Period types to precompute. Defaults to weekly, monthly and quarterly." }, "group_dims": { "type": "array", "items": { "type": "string", "enum": [ "payee", "tag" ] }, "default": [ "payee", "tag" ], "x-order": 2, "x-label": "Grouping", "description": "Grouping dimensions to precompute (payee, tag hierarchy with roll-up). Defaults to both." }, "flow": { "type": "string", "enum": [ "both", "inflows", "outflows" ], "default": "both", "x-order": 3, "x-label": "Flow", "description": "Which flows to build. Defaults to both." }, "accounts": { "type": "array", "items": { "type": "string" }, "x-order": 4, "x-label": "Accounts", "description": "Restrict to these account names (empty = all accounts).", "x-fk": { "resource": "accounts", "value": "name" } }, "ignore_self": { "type": "boolean", "default": true, "x-order": 5, "x-label": "Ignore Self-Transfers" }, "start_date": { "type": "string", "format": "date", "x-order": 6, "x-label": "Start Date", "description": "Inclusive range start \u2014 accepts DD-MM-YYYY, YYYY-MM-DD or DD/MM/YYYY (empty = unbounded)." }, "end_date": { "type": "string", "format": "date", "x-order": 7, "x-label": "End Date", "description": "Inclusive range end \u2014 accepts DD-MM-YYYY, YYYY-MM-DD or DD/MM/YYYY (empty = unbounded)." }, "min_amount": { "type": "number", "x-order": 8, "x-label": "Min Amount", "description": "Only include transactions with abs(amount) >= this value." }, "max_amount": { "type": "number", "x-order": 9, "x-label": "Max Amount", "description": "Only include transactions with abs(amount) <= this value." } } }, "ReportSnapshot": { "type": "object", "x-resource": "reports", "x-primary-key": "id", "x-display-format": "{name}", "x-list-columns": [ "name", "generated_at", "created_at" ], "required": [ "id", "name", "query", "signature", "generated_at" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true, "list": true }, "x-label": "ID" }, "name": { "type": "string", "x-order": 1, "x-label": "Name" }, "query": { "$ref": "#/components/schemas/ReportQuery", "x-order": 2, "x-hidden": { "form": true }, "x-label": "Query" }, "signature": { "type": "string", "x-order": 3, "x-hidden": { "form": true, "list": true, "detail": true }, "x-label": "Signature", "description": "Canonical fingerprint of the snapshot definition (idempotency / dedup)." }, "generated_at": { "type": "string", "format": "date-time", "x-order": 4, "x-hidden": { "form": true }, "x-label": "Generated" }, "created_at": { "type": "string", "format": "date-time", "x-order": 5, "x-label": "Created", "x-hidden": { "form": true, "list": true } } } }, "Report": { "type": "object", "x-resource": "reports", "x-primary-key": "id", "required": [ "id", "name", "query", "flow", "granularities", "buckets" ], "properties": { "id": { "type": "string", "x-order": 0, "x-hidden": { "form": true }, "x-label": "ID" }, "name": { "type": "string", "x-order": 1, "x-label": "Name" }, "query": { "$ref": "#/components/schemas/ReportQuery", "x-order": 2, "x-hidden": { "form": true }, "x-label": "Query" }, "generated_at": { "type": "string", "format": "date-time", "x-order": 3, "x-hidden": { "form": true }, "x-label": "Generated" }, "created_at": { "type": "string", "format": "date-time", "x-order": 4, "x-hidden": { "form": true, "list": true }, "x-label": "Created" }, "flow": { "type": "string", "enum": [ "inflows", "outflows" ], "x-order": 5, "x-hidden": { "form": true }, "x-label": "Flow" }, "granularities": { "type": "array", "items": { "type": "string" }, "x-order": 6, "x-hidden": { "form": true }, "x-label": "Granularities" }, "period_ids": { "type": "array", "items": { "type": "string" }, "x-order": 7, "x-hidden": { "form": true }, "x-label": "Period IDs" }, "payees": { "type": "array", "items": { "type": "string" }, "x-order": 8, "x-hidden": { "form": true }, "x-label": "Payees" }, "tags": { "type": "array", "items": { "type": "string" }, "x-order": 9, "x-hidden": { "form": true }, "x-label": "Tags" }, "buckets": { "type": "array", "items": { "$ref": "#/components/schemas/ReportBucket" }, "x-order": 10, "x-hidden": { "form": true }, "x-label": "Buckets" } } }, "ReportMetrics": { "type": "object", "x-display-format": "\u20b9{sum}", "properties": { "outflows": { "type": "number", "x-order": 1, "x-label": "Outflows", "description": "Total absolute value of negative (outflow) amounts." }, "inflows": { "type": "number", "x-order": 2, "x-label": "Inflows", "description": "Total of positive (inflow) amounts." }, "sum": { "type": "number", "x-order": 3, "x-label": "Sum" }, "count": { "type": "integer", "x-order": 4, "x-label": "Count" }, "avg": { "type": "number", "x-order": 5, "x-label": "Avg" }, "min": { "type": "number", "x-order": 6, "x-label": "Min" }, "max": { "type": "number", "x-order": 7, "x-label": "Max" }, "first_date": { "type": "string", "x-order": 8, "x-label": "First" }, "last_date": { "type": "string", "x-order": 9, "x-label": "Last" }, "cadence": { "type": "number", "x-order": 10, "x-label": "Cadence (days)", "description": "Average gap between consecutive transactions, in days." }, "frequency": { "type": "number", "x-order": 11, "x-label": "Frequency (per day)", "description": "Average transactions per day (inverse of cadence); meaningful when several transactions share a day." } } }, "ReportPeriod": { "type": "object", "properties": { "period_id": { "type": "string", "x-order": 1, "x-label": "Period", "description": "Canonical period label, e.g. 2026-May, 2026-W18, 2026-05-01, 2026." }, "start": { "type": "string", "x-order": 2, "x-label": "Start", "description": "Inclusive calendar start (ISO date)." }, "end": { "type": "string", "x-order": 3, "x-label": "End", "description": "Exclusive calendar end (ISO date)." }, "metrics": { "$ref": "#/components/schemas/ReportMetrics", "x-order": 4, "x-label": "Metrics" }, "txns": { "type": "array", "items": { "$ref": "#/components/schemas/Expense" }, "x-order": 5, "x-label": "Transactions" } } }, "ReportBucket": { "type": "object", "properties": { "group_key": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } }, "x-order": 1, "x-label": "Group", "description": "The grouping slice (e.g. {\"payee\": [\"Zepto\"], \"tag\": [\"food\"]}), or {} for the global series." }, "series": { "type": "object", "additionalProperties": { "type": "array", "items": { "$ref": "#/components/schemas/ReportPeriod" } }, "x-order": 2, "x-label": "Series", "description": "Map of granularity to its period series (granularities with no data are omitted)." } } }, "ProgressStep": { "type": "string", "enum": [ "raw_lines", "txn_blocks", "txn_dicts", "resume_extract", "extract", "paused", "raw_expenses", "enrich_entity", "enrich_tags", "enrich", "save_expenses", "llama_extract" ] }, "ProgressStatus": { "type": "string", "enum": [ "started", "completed", "skipped", "paused", "progress", "failed" ] } } } }