{"openapi":"3.1.0","info":{"title":"Finance Planner API","version":"0.13.1","description":"OIDC-authenticated backend for the Finance Planner web app: financial accounts, balance recordings, net-worth forecasting items, and per-user account statement document uploads."},"servers":[{"url":"https://finance.jalexw.ca"}],"components":{"securitySchemes":{"SessionCookie":{"type":"apiKey","in":"cookie","name":"sv_finance_session","description":"Encrypted session cookie issued by the OIDC login flow (GET /api/auth/login)."}},"schemas":{"AccountReference":{"type":"object","properties":{"type":{"type":"string","enum":["chequing","credit","personal-loan"]},"label":{"type":"string","minLength":1,"maxLength":32},"id":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"}},"required":["type","label","id"],"additionalProperties":false},"BalanceRecording":{"type":"object","properties":{"balance":{"type":"number"},"timestamp":{"type":"number"}},"required":["balance","timestamp"],"additionalProperties":false},"StatementTransaction":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"type":"string"},"documentId":{"type":["string","null"],"format":"uuid"},"description":{"type":["string","null"]},"amount":{"type":"number"},"transactionAt":{"type":"integer"},"source":{"type":"string","enum":["manual","ai"]},"sourcePage":{"type":["integer","null"],"exclusiveMinimum":0},"sourceText":{"type":["string","null"]},"tags":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"},"scope":{"type":"string","enum":["global","user"]}},"required":["id","label","scope"],"additionalProperties":false}}},"required":["id","accountId","documentId","description","amount","transactionAt","source","sourcePage","sourceText","tags"],"additionalProperties":false},"RecordBalanceRequest":{"type":"object","properties":{"balance":{"type":"number"},"timestamp":{"type":"integer","minimum":0}},"required":["balance"],"additionalProperties":false},"LatestBalance":{"type":"object","properties":{"balance":{"type":["number","null"]},"timestamp":{"type":["number","null"]}},"required":["balance","timestamp"]},"CreateTransactionRequest":{"type":"object","properties":{"description":{"type":["string","null"],"minLength":1,"maxLength":500},"amount":{"type":"number"},"transactionAt":{"type":"integer","minimum":0},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":20}},"required":["description","amount","transactionAt"],"additionalProperties":false},"SetTransactionTagsRequest":{"type":"object","properties":{"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":20}},"required":["tagIds"],"additionalProperties":false},"SpendingBreakdown":{"type":"object","properties":{"accountId":{"type":["string","null"]},"from":{"type":"integer"},"to":{"type":"integer"},"totalSpending":{"type":"number"},"totalIncome":{"type":"number"},"transactionCount":{"type":"integer","minimum":0},"categories":{"type":"array","items":{"type":"object","properties":{"tagId":{"type":["string","null"],"format":"uuid"},"label":{"type":"string"},"amount":{"type":"number"},"transactionCount":{"type":"integer","minimum":0}},"required":["tagId","label","amount","transactionCount"],"additionalProperties":false}}},"required":["accountId","from","to","totalSpending","totalIncome","transactionCount","categories"],"additionalProperties":false},"IncomeBreakdown":{"type":"object","properties":{"accountId":{"type":["string","null"]},"from":{"type":"integer"},"to":{"type":"integer"},"totalIncome":{"type":"number"},"totalSpending":{"type":"number"},"transactionCount":{"type":"integer","minimum":0},"categories":{"type":"array","items":{"type":"object","properties":{"tagId":{"type":["string","null"],"format":"uuid"},"label":{"type":"string"},"amount":{"type":"number"},"transactionCount":{"type":"integer","minimum":0}},"required":["tagId","label","amount","transactionCount"],"additionalProperties":false}}},"required":["accountId","from","to","totalIncome","totalSpending","transactionCount","categories"],"additionalProperties":false},"Budget":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":120},"isDefault":{"type":"boolean"}},"required":["id","name","isDefault"],"additionalProperties":false},"UpsertBudgetRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120}},"required":["name"],"additionalProperties":false},"AnticipatedFinancialChange":{"type":"object","properties":{"uuid":{"type":"string","format":"uuid"},"amount":{"type":"number","minimum":0},"start":{"type":"integer","minimum":0},"type":{"type":"string","enum":["income","expense"]},"description":{"type":"string"},"frequency":{"type":"integer","minimum":0},"finish":{"type":"integer","minimum":0}},"required":["uuid","amount","start","type"],"additionalProperties":false},"NetWorthSnapshot":{"type":"object","properties":{"at":{"type":"integer"},"netWorth":{"type":"number"},"accounts":{"type":"array","items":{"type":"object","properties":{"accountId":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"label":{"type":"string","minLength":1,"maxLength":32},"type":{"type":"string","enum":["chequing","credit","personal-loan"]},"balance":{"type":"number"},"netWorthContribution":{"type":"number"}},"required":["accountId","label","type","balance","netWorthContribution"],"additionalProperties":false}}},"required":["at","netWorth","accounts"],"additionalProperties":false},"NetWorthHistory":{"type":"object","properties":{"from":{"type":["integer","null"]},"to":{"type":"integer"},"points":{"type":"array","items":{"type":"object","properties":{"time":{"type":"number"},"price":{"type":"number"}},"required":["time","price"],"additionalProperties":false}}},"required":["from","to","points"],"additionalProperties":false},"StatementDocument":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"type":["string","null"]},"filename":{"type":"string"},"contentType":{"type":"string"},"sizeBytes":{"type":"integer","minimum":0},"status":{"type":"string","enum":["uploaded","parsing","parsed","failed"]},"uploadedAt":{"type":"integer","minimum":0},"openingBalance":{"type":["number","null"]},"closingBalance":{"type":["number","null"]},"periodStartAt":{"type":["integer","null"]},"periodEndAt":{"type":["integer","null"]},"parsedWithVersion":{"type":["string","null"]}},"required":["id","accountId","filename","contentType","sizeBytes","status","uploadedAt","openingBalance","closingBalance","periodStartAt","periodEndAt","parsedWithVersion"],"additionalProperties":false},"StatementParseResult":{"type":"object","properties":{"transactionCount":{"type":"integer","minimum":0}},"required":["transactionCount"],"additionalProperties":false},"Tag":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"},"scope":{"type":"string","enum":["global","user"]}},"required":["id","label","scope"],"additionalProperties":false},"CreateTagRequest":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":50}},"required":["label"],"additionalProperties":false},"ErrorResponse":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]},"SessionInfo":{"type":"object","properties":{"user":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"sub":{"type":"string","minLength":1},"iss":{"type":"string","minLength":1},"email":{"type":"string"},"name":{"type":"string"},"picture":{"type":"string"}},"required":["userId","sub","iss"],"additionalProperties":false}},"required":["user"]},"Health":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}},"parameters":{}},"paths":{"/api/auth/login":{"get":{"tags":["auth"],"summary":"Start the OIDC login flow (authorization code + PKCE) at the identity provider","parameters":[{"schema":{"type":"string","description":"Same-site path to return to after login (default /home)"},"required":false,"description":"Same-site path to return to after login (default /home)","name":"returnTo","in":"query"}],"responses":{"302":{"description":"Redirect to the identity provider"}}}},"/api/auth/callback":{"get":{"tags":["auth"],"summary":"OAuth2 redirect URI: exchanges the authorization code (with client secret) and establishes a session","responses":{"302":{"description":"Redirect back into the application"},"400":{"description":"Missing/expired login transaction or invalid response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/auth/logout":{"get":{"tags":["auth"],"summary":"Clear the session and redirect through the identity provider's logout, when available","responses":{"302":{"description":"Redirect to the identity provider logout or home"}}}},"/api/auth/me":{"get":{"tags":["auth"],"summary":"Return the authenticated user's session info","security":[{"SessionCookie":[]}],"responses":{"200":{"description":"The authenticated user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInfo"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts":{"get":{"tags":["accounts"],"summary":"List the authenticated user's financial accounts","security":[{"SessionCookie":[]}],"responses":{"200":{"description":"The user's financial accounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AccountReference"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["accounts"],"summary":"Create a financial account","security":[{"SessionCookie":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountReference"}}}},"responses":{"201":{"description":"The created account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountReference"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"An account with that ID already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts/{accountId}":{"get":{"tags":["accounts"],"summary":"Get one financial account","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"responses":{"200":{"description":"The account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountReference"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"put":{"tags":["accounts"],"summary":"Update a financial account's type/label","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountReference"}}}},"responses":{"200":{"description":"The updated account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountReference"}}}},"400":{"description":"Body account ID does not match the URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["accounts"],"summary":"Delete a financial account (its balance recordings, statements, and parsed transactions are removed too)","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"responses":{"204":{"description":"Account deleted"},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts/{accountId}/balances":{"get":{"tags":["balances"],"summary":"List manually recorded balances for an account","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"responses":{"200":{"description":"Balance recordings, oldest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BalanceRecording"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["balances"],"summary":"Record the account's current balance","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordBalanceRequest"}}}},"responses":{"201":{"description":"The recorded balance","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BalanceRecording"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts/{accountId}/balances/latest":{"get":{"tags":["balances"],"summary":"Get the most recently recorded balance for an account","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"responses":{"200":{"description":"The latest balance (null when none recorded yet)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LatestBalance"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts/{accountId}/transactions":{"get":{"tags":["transactions"],"summary":"List the account's transactions (manually entered and AI-identified)","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"responses":{"200":{"description":"Transactions, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StatementTransaction"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["transactions"],"summary":"Manually record a transaction against the account","description":"Creates a transaction with source='manual', distinguishing it from rows identified by AI statement parsing.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTransactionRequest"}}}},"responses":{"201":{"description":"The recorded transaction","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatementTransaction"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts/{accountId}/transactions/{transactionId}":{"delete":{"tags":["transactions"],"summary":"Delete one transaction (manually entered or AI-identified)","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"transactionId","in":"path"}],"responses":{"204":{"description":"Transaction deleted"},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account or transaction not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/accounts/{accountId}/transactions/{transactionId}/tags":{"put":{"tags":["tags"],"summary":"Replace the set of tags assigned to a transaction","description":"Assigns exactly the given tags (global or the user's custom tags) to the transaction, removing any previously assigned tags not in the list.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":true,"name":"accountId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"transactionId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetTransactionTagsRequest"}}}},"responses":{"200":{"description":"The updated transaction","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatementTransaction"}}}},"400":{"description":"One or more tag ids do not exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account or transaction not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/analytics/spending":{"get":{"tags":["analytics"],"summary":"Break spending down by category over a time range","description":"Totals the user's money-out transactions between `from` and `to` by the tags assigned to them, for one financial account or across all of them. A transaction carrying several tags has its amount split evenly between them, so the categories always add up to the total spent; transactions with no tags are collected into an \"Untagged\" category.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":false,"name":"accountId","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"from","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"to","in":"query"}],"responses":{"200":{"description":"Spending over the window, by category, largest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpendingBreakdown"}}}},"400":{"description":"The window ends before it starts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/analytics/income":{"get":{"tags":["analytics"],"summary":"Break income down by category over a time range","description":"Totals the user's money-in transactions between `from` and `to` by the tags assigned to them, for one financial account or across all of them. A transaction carrying several tags has its amount split evenly between them, so the categories always add up to the total received; transactions with no tags are collected into an \"Untagged\" category.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":false,"name":"accountId","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"from","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"to","in":"query"}],"responses":{"200":{"description":"Income over the window, by category, largest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncomeBreakdown"}}}},"400":{"description":"The window ends before it starts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/budgets":{"get":{"tags":["budgets"],"summary":"List the user's budgets","description":"Budgets are named collections of forecasting items; the one flagged isDefault drives the dashboard's net-worth forecast until another budget is selected. Users start with an automatically created \"My Budget\", so the list is never empty.","security":[{"SessionCookie":[]}],"responses":{"200":{"description":"The user's budgets, oldest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Budget"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["budgets"],"summary":"Create a budget","description":"The user's first budget automatically becomes their default budget.","security":[{"SessionCookie":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertBudgetRequest"}}}},"responses":{"201":{"description":"The created budget","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A budget with that name already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/budgets/{budgetId}":{"put":{"tags":["budgets"],"summary":"Rename a budget","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"budgetId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertBudgetRequest"}}}},"responses":{"200":{"description":"The renamed budget","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Budget not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A budget with that name already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["budgets"],"summary":"Delete a budget (its forecasting items are removed too)","description":"Deleting the default budget promotes the user's oldest remaining budget to be the new default.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"budgetId","in":"path"}],"responses":{"204":{"description":"Budget deleted"},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Budget not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/budgets/{budgetId}/default":{"put":{"tags":["budgets"],"summary":"Make a budget the user's default","description":"The default budget is the one the dashboard's net-worth forecast loads with; setting it clears the flag from any other budget.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"budgetId","in":"path"}],"responses":{"200":{"description":"The new default budget","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Budget not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/budgets/{budgetId}/forecasting-items":{"get":{"tags":["forecasting"],"summary":"List the budget's anticipated financial changes","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"budgetId","in":"path"}],"responses":{"200":{"description":"Anticipated financial changes used for net-worth forecasting","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AnticipatedFinancialChange"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Budget not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["forecasting"],"summary":"Add an anticipated financial change to the budget","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"budgetId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnticipatedFinancialChange"}}}},"responses":{"201":{"description":"The created forecasting item","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnticipatedFinancialChange"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Budget not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A forecasting item with that UUID already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/budgets/{budgetId}/forecasting-items/{uuid}":{"delete":{"tags":["forecasting"],"summary":"Remove an anticipated financial change from the budget","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"budgetId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"uuid","in":"path"}],"responses":{"204":{"description":"Forecasting item deleted"},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Budget or forecasting item not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/networth":{"get":{"tags":["networth"],"summary":"Calculate net worth on a given date","description":"Totals every account's balance as it stood on `at` (defaulting to now), and reports the per-account balances it was added up from. Each account is valued from the balance recorded nearest the requested date, with the transactions in between applied to carry that balance forwards — or unwound to carry it backwards, so net worth can be valued before the balance was ever recorded. Balances held (chequing) add to net worth and balances owed (credit cards, personal loans) subtract from it. An account whose balance has never been recorded is anchored at zero at its latest transaction: it counts as zero today, but its transactions still reconstruct how it moved before then.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"at","in":"query"}],"responses":{"200":{"description":"Net worth on that date, and the balances behind it","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NetWorthSnapshot"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/networth/history":{"get":{"tags":["networth"],"summary":"Reconstruct net worth across a window of time","description":"Values net worth at roughly `points` evenly spaced moments between `from` and `to`, finishing exactly on `to`. The series starts no earlier than the oldest balance or transaction on record, so it can be shorter than the requested window, and is empty when nothing has been recorded yet. Passing `accounts` reconstructs from just those accounts, as if the others did not exist. Each account is valued from the balance recorded nearest the requested date, with the transactions in between applied to carry that balance forwards — or unwound to carry it backwards, so net worth can be valued before the balance was ever recorded. Balances held (chequing) add to net worth and balances owed (credit cards, personal loans) subtract from it. An account whose balance has never been recorded is anchored at zero at its latest transaction: it counts as zero today, but its transactions still reconstruct how it moved before then.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"from","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"to","in":"query"},{"schema":{"type":"integer","minimum":2,"maximum":1000},"required":false,"name":"points","in":"query"},{"schema":{"type":"string"},"required":false,"name":"accounts","in":"query"}],"responses":{"200":{"description":"Net worth over the window","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NetWorthHistory"}}}},"400":{"description":"The window ends before it starts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/documents":{"get":{"tags":["documents"],"summary":"List the user's uploaded statement documents","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"},"required":false,"name":"accountId","in":"query"}],"responses":{"200":{"description":"Uploaded statement documents, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StatementDocument"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["documents"],"summary":"Upload an account statement document (stored privately in blob storage)","description":"Multipart form upload. Fields: `file` (the statement: PDF, CSV, OFX/QFX, or an image) and optional `accountId` (slug of the financial account the statement belongs to). Uploaded documents will eventually be parsed with AI to identify transactions.","security":[{"SessionCookie":[]}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary"},"accountId":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[a-z0-9-]+$"}},"required":["file"]}}}},"responses":{"201":{"description":"The uploaded document","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatementDocument"}}}},"400":{"description":"Missing file, unsupported type, or unknown account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"File exceeds the upload size limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/documents/{documentId}":{"get":{"tags":["documents"],"summary":"Get a statement document's metadata","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"documentId","in":"path"}],"responses":{"200":{"description":"The document","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatementDocument"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Document not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["documents"],"summary":"Delete a statement document (metadata and stored blob)","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"documentId","in":"path"}],"responses":{"204":{"description":"Document deleted"},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Document not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/documents/{documentId}/content":{"get":{"tags":["documents"],"summary":"Download a statement document's content","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"documentId","in":"path"}],"responses":{"200":{"description":"The document content","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Document (or its stored content) not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/documents/{documentId}/transactions":{"get":{"tags":["transactions"],"summary":"List the transactions identified inside one statement document","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"documentId","in":"path"}],"responses":{"200":{"description":"Transactions parsed from this document, in statement order","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"accountId":{"type":"string"},"documentId":{"type":["string","null"],"format":"uuid"},"description":{"type":["string","null"]},"amount":{"type":"number"},"transactionAt":{"type":"integer"},"source":{"type":"string","enum":["manual","ai"]},"sourcePage":{"type":["integer","null"],"exclusiveMinimum":0},"sourceText":{"type":["string","null"]},"tags":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"},"scope":{"type":"string","enum":["global","user"]}},"required":["id","label","scope"],"additionalProperties":false}}},"required":["id","accountId","documentId","description","amount","transactionAt","source","sourcePage","sourceText","tags"],"additionalProperties":false}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Document not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/documents/{documentId}/parse":{"post":{"tags":["documents"],"summary":"Parse the statement with AI to identify transactions","description":"Sends the stored statement to Claude, which identifies every transaction row (with the page and verbatim statement line it came from). Identified transactions are written to the account's transaction list with source='ai', replacing any AI-identified rows from a previous parse of the same document. The AI also assigns each transaction any of the user's tags that clearly apply, leaving transactions untagged when no category is obvious. Statement-level details the statement explicitly prints — opening balance, closing balance, and the statement period — are stored on the document when found, and null when not (the AI never estimates them). The app version that ran the parse is recorded on the document as `parsedWithVersion`, so documents whose results predate parser improvements can be found and re-parsed.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"documentId","in":"path"}],"responses":{"200":{"description":"Parsing finished; transactions were written to the account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatementParseResult"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Document (or its stored content) not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Document is not linked to a financial account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Statement is too large for AI parsing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"415":{"description":"Unsupported content type for AI parsing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"The AI could not parse the statement","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"AI parsing is not configured on this deployment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/tags":{"get":{"tags":["tags"],"summary":"List the tags available for the user's transactions","description":"Returns the global tags (common spending categories shared by every user) together with the user's own custom tags, ordered by label.","security":[{"SessionCookie":[]}],"responses":{"200":{"description":"Available tags, ordered by label","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Tag"}}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["tags"],"summary":"Create a custom tag","description":"Creates a tag visible only to the authenticated user. Labels are unique case-insensitively across the global tags and the user's own.","security":[{"SessionCookie":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTagRequest"}}}},"responses":{"201":{"description":"The created tag","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tag"}}}},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Authenticated user is not in AUTHORIZED_OIDC_SUBS_WHITELIST","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A tag with that label already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/tags/{tagId}":{"delete":{"tags":["tags"],"summary":"Delete one of the user's custom tags","description":"Deletes a custom tag and removes it from every transaction it was assigned to. Global tags cannot be deleted.","security":[{"SessionCookie":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"tagId","in":"path"}],"responses":{"204":{"description":"Tag deleted"},"401":{"description":"Not authenticated (no valid session cookie)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Global tags cannot be deleted (or the user is not authorized)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Tag not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/healthz":{"get":{"tags":["meta"],"summary":"Liveness probe","responses":{"200":{"description":"Service is up","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/api/openapi.json":{"get":{"tags":["meta"],"summary":"OpenAPI 3.1 description of this API","responses":{"200":{"description":"The OpenAPI document"}}}}},"webhooks":{}}