Skip to content

API Design Conventions

This document defines the conventions for designing and implementing REST API endpoints in the Patrimoine backend.


Route prefix Purpose Versioned
/api/v1/ All resource routes (accounts, snapshots, charts, etc.) Yes
/api/v1/auth/ Authenticated account self-service (profile, password, TOTP, email change, token revoke) Yes
/auth/ Unauthenticated session- and credential-establishing flows, and externally registered or email-linked URLs (OIDC start/callback/logout, config, login, register, invites, password reset) No (public)
/health Health check endpoint No (public)

Resource routes are always versioned. Auth routes split by whether they establish a session/credential or are externally registered — see “Auth route groups” below. Health routes are intentionally unversioned.


app/routers/auth.py declares two routers rather than one, each mounted with its prefix at include time like every other router (see “Router Pattern” below):

  • public_router (mounted at /auth): unauthenticated flows that establish or end a session or credential, or whose URLs are registered externally or linked from email — OIDC start/callback/logout, config, register, invite validate/redeem, login, token login, mobile exchange, forgot/reset password.
  • account_router (mounted at /api/v1/auth): versioned account self-service — profile (me), password change, TOTP setup/verify/disable, email change, and API token revoke.

Routers are defined without a prefix at construction time. The prefix is applied when the router is included in the application:

# In the router module (e.g., app/routers/accounts.py)
router = APIRouter()
@router.get("/")
async def list_accounts(...):
...
# In app/main.py
app.include_router(router, prefix="/api/v1/accounts", tags=["accounts"])

This keeps route definitions portable and centralizes prefix management in main.py.


Most endpoints are ownership-scoped: Depends(get_current_user) plus filtering by user.id. A small number of endpoints (currently only /api/v1/admin/*) are role-gated instead: they use Depends(get_current_admin_user), which wraps get_current_user and raises APIError(code="admin_required", status_code=403) for non-admins.

Role-gated endpoints must still respect ownership boundaries where relevant — the admin role in this app is scoped to user account management only (enable/disable, promote/demote, invite). It does not grant visibility into other users’ financial data (accounts, snapshots, transactions, etc.); no admin endpoint may query those tables across users.


Pydantic schemas are split into Create, Update, and Out variants per model. This prevents mass-assignment vulnerabilities by ensuring each endpoint only accepts the fields it needs.

Schema Purpose Used by
AccountCreate Fields allowed on creation POST /api/v1/accounts
AccountUpdate Fields allowed on update (all optional) PATCH /api/v1/accounts/{id}
AccountOut Fields returned to the client All response bodies

Never reuse a single schema for both input and output. The Out schema must never expose internal fields like user_id, hashed_password, etc.


All error responses follow a standardized structure:

{
"error": {
"code": "not_found",
"message": "Account not found",
"detail": "No account with id 42 belongs to the current user"
}
}

The detail field is optional and provides additional context when available.

The table below is only the fallback used by app/errors.py’s http_exception_handler when a route raises a bare HTTPException — which is rare.

In practice, almost every real endpoint error is raised as APIError with a resource-specific semantic code (account_not_found, category_not_found, tag_not_found, webhook_not_found, planned_transaction_not_found, budget_not_found, holding_not_found, rule_not_found, api_token_invalid, plus many *_conflict/*_mismatch codes, etc.), not one of the generic codes below.

See the API Reference’s per-endpoint error-code documentation for the actual codes an endpoint returns.

HTTP Status Error Code (bare HTTPException fallback only)
400 bad_request
401 unauthorized
403 forbidden
404 not_found
405 method_not_allowed
409 conflict
422 validation_error
429 rate_limited
500 internal_error

Authentication failures raised by get_current_user are APIError responses with distinct codes and unchanged statuses: not_authenticated (401, no usable credentials), token_expired (401, the session passed its absolute lifetime), token_revoked (401), invalid_api_token (401), session_invalid (401, the session was ended by a credential change or its user no longer exists) and account_disabled (403). Clients should decide on the HTTP status, with one exception: a 401 is also raised for a wrong password or TOTP code (invalid_credentials, invalid_password, totp_invalid, totp_incorrect_password, totp_required), where the credential is fine and the session must not end. The Android app therefore ends its session only on the codes it lists in ApiErrorCodes.kt as “credential rejected” (or on a 401 with no readable code), and backend/tests/test_android_error_contract.py fails when the backend raises a 401 code that list does not classify.

New endpoints should raise APIError with a specific code, not rely on this fallback map.

Any exception no handler takes becomes a 500 with code internal_error and the generic message Internal server error; nothing about the cause reaches the client. The traceback is logged server-side with the request id, which is also returned in the X-Request-ID response header.


All protected endpoints use FastAPI dependency injection:

from app.database import get_db
from app.dependencies import get_current_user
@router.get("/")
async def list_accounts(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
...
  • Depends(get_current_user) — Validates the JWT/session token and returns the authenticated User. Raises 401 if unauthenticated.
  • Depends(get_db) — Provides an async database session.

No endpoint should accept a user_id from the request body or query parameters. The user identity is always derived from the authentication token.

State-changing requests (POST/PATCH/PUT/DELETE) must carry an X-Requested-With header, enforced by app/middleware/csrf.py’s CSRFMiddleware. A request missing it on a cookie-authenticated session is rejected with 403 forbidden. This is exempt for:

  • Safe methods (GET, HEAD, OPTIONS)
  • /health
  • E2E test mode (settings.e2e_test_mode)
  • Any request carrying Authorization: Bearer ptr_...

API tokens (the ptr_-prefixed Bearer tokens issued under /api/v1/tokens) bypass CSRF entirely — they’re not cookie-based, so there’s no CSRF risk.

A new endpoint doesn’t need to do anything special to get this: the middleware applies globally.


List endpoints accept limit and offset query parameters and return the total count via the X-Total-Count response header.

GET /api/v1/accounts?limit=20&offset=0
Response Headers:
X-Total-Count: 47
  • limit — Maximum number of items to return (default and max enforced server-side)
  • offset — Number of items to skip (zero-based)
  • X-Total-Count — Total number of items matching the query (before pagination)

Totals that add balances across currencies convert a currency that has no exchange rate at parity and name those currencies in an X-Missing-Rates response header (for example JPY,USD), so the figures are never silently wrong. Use rate_or_par() from app/utils/fx_flags.py instead of rates.get(currency, Decimal("1")). The web app hides its missing-rates notice while every currency the headers named is in the user’s missing_rates_dismissed profile field; dismissing the notice adds those currencies to it.

Standard shape: limit defaults to 50 with a maximum of 250; offset defaults to 0.

Standard-shape endpoints take page: Pagination = Depends(pagination_params) from app/dependencies.py instead of repeating the two Query(...) declarations.

Read page.limit/page.offset where a router previously read separate limit/offset parameters — the service call and the X-Total-Count header are unchanged.

A new list endpoint uses the standard shape above unless an existing client dependency is documented in this table.

Endpoint Default Maximum Reason
GET /api/v1/exchange-rates 50 2000 The web app’s per-pair history views (useExchangeRateHistoryQuery) fetch a pair’s full rate history in one request via limit=2000.
GET /api/v1/subcategories 250 250 The web and Android clients fetch the whole subcategory list with no explicit limit; a lower default would silently truncate it.
GET /api/v1/snapshots (limit) 250 500 Row-count pagination for large per-account snapshot histories; a caller needing everything pages through offset via X-Total-Count rather than relying on an unbounded default.
GET /api/v1/snapshots (date_limit) none (opt-in) 100 Alternate date-pivot pagination mode used by the web UI’s cards view — pages by distinct date so a page is never split across a date boundary.
GET /api/v1/institutions/grid (limit) 25 250 The institution-grouped, all-history pivot grid paginates by distinct date.
GET /api/v1/budget/budgets 250 250 The web app (useBudget.ts) and Android (PatrimoineApi.kt) both fetch the whole budgets list with no explicit limit; a lower default would silently truncate it.
GET /api/v1/accounts/deleted/list 250 250 The web app’s createCrudHooks.ts deleted-list query and Android’s PatrimoineApi.kt both fetch the whole trash list with no explicit limit.
GET /api/v1/institutions/deleted/list 250 250 Same shipped-client dependency as the accounts trash list above.
GET /api/v1/categorization-rules/deleted/list 250 250 Same shipped-client dependency as the accounts trash list above.
GET /api/v1/planned-transactions/deleted/list 250 250 Same shipped-client dependency as the accounts trash list above.
GET /api/v1/snapshots/grid (limit) none (opt-in) 250 The pivoted date-vs-account grid paginates by distinct date, like the institutions grid above; omitting limit returns every matching date unbounded, matching prior behavior for callers that page entirely via date_from/date_to.
GET /api/v1/budget/transactions/ids none 50000 Backs the web app’s “select all N matching” bulk-select flow, which needs every matching id at once. A match above 50000 is refused with 422 too_many_results (detail.max_results) instead of being truncated; the caller narrows its filters.
GET /api/v1/budget/transactions/converted none 50000 The web app’s spending charts (useTransactionsConvertedQuery) load the full filtered set, since a capped page silently undercounted “ALL” ranges. Same 422 too_many_results refusal above 50000 rows.
GET /api/v1/snapshots/latest-per-account none one row per account Prefill for the snapshot entry form: returns the latest snapshot of each account, so its size is bounded by the number of accounts and an offset window would drop accounts from the form.
GET /api/v1/market-data/search none upstream Ticker search results come from the quote provider, which bounds their number; there is no stored row set to page through.

Chart endpoints under /api/v1/charts/* return aggregated time series (net-worth points per snapshot date, one trend series per account, stacked series per account type, statistics/heatmap/waterfall objects) bounded by the caller’s own from/to date-range parameters, not by an entity-row count. POST /api/v1/charts/balance-forecast/what-if takes the hypothetical items in its body and is read-only. GET /api/v1/charts/balance-forecast is the forward-looking exception, with no from/to: it projects from today and is bounded by its days parameter (1 to 365, so at most 366 daily points), returned as one object and likewise without limit/offset.

They are intentionally not given limit/offset: an offset window over a series would skip or truncate points and render an incorrect chart, and both shipped clients call these endpoints without paging parameters today.


Two different conventions select an export’s file format, and both are intentional:

  • Path-per-format, for downloadable files of user data where each format is a distinct file type, filename and media type: GET /api/v1/export/csv|ods|xlsx|backup and GET /api/v1/export/holdings/csv|ods|xlsx (app/routers/export.py), and GET /api/v1/budget/transactions/export/{fmt} (the transactions export).
  • ?format=, for alternative serializations (CSV/JSON) of one filtered list query: GET /api/v1/audit-logs/export?format= (app/routers/audit_logs.py) and the admin audit export in app/routers/admin_users.py. Both are capped at 50,000 rows (newest first); a capped response carries X-Truncated: true.

Rule for new endpoints: if the export is a full file download of a resource (a distinct filename and media type per format, no other query filters that change the underlying result set), use path-per-format. If the export is a serialization choice over an otherwise-filtered list query (the same rows, just CSV vs JSON), use ?format=.


The OpenAPI specification is committed as backend/openapi.json. This ensures the API contract is version-controlled and reviewable.

CI verifies that the committed schema matches the code:

Terminal window
cd backend && uv run python scripts/export_openapi.py --check

If the check fails, the schema is out of date. Regenerate it:

Terminal window
cd backend && uv run python scripts/export_openapi.py

Always regenerate and commit openapi.json after making API changes (new endpoints, changed schemas, new parameters, etc.).


All database queries must filter by user_id derived from get_current_user. A user must never be able to access another user’s data.

stmt = select(Account).where(Account.user_id == user.id)

Never trust a user_id or owner_id from the client. Always use the authenticated user’s ID.


Entities that support soft-delete (Account, Snapshot, Institution, Transaction) use a deleted_at: datetime | None column. When set, the record is excluded from normal list queries but preserved for potential restoration.

All soft-deletable entities follow the same URL pattern:

Action Method URL Pattern Description
Soft-delete DELETE /{entity}/{id} Sets deleted_at; cascades to child entities
Restore POST /{entity}/{id}/restore Clears deleted_at; cascades to child entities
Permanent delete DELETE /{entity}/{id}/permanent Hard-deletes the row (only works on already soft-deleted records); CASCADE FK removes children
List deleted GET /{entity}/deleted/list Returns only soft-deleted records
Empty trash DELETE /{entity}/deleted/empty Permanently deletes all soft-deleted records for the user

Transaction follows the same convention under /api/v1/budget: GET /transactions/deleted/list is paginated (limit/offset, X-Total-Count), POST /transactions/{id}/restore, DELETE /transactions/{id}/permanent and DELETE /transactions/deleted/empty. DELETE /transactions/{id} and POST /transactions/batch-delete move rows to the trash with their HTTP contracts unchanged. Nothing purges the trash automatically.

  • Institution → cascades to linked Accounts and their Snapshots (both soft-delete and restore)
  • Account → cascades to linked Snapshots (restore only; soft-delete of account does not cascade-delete snapshots individually, they become inaccessible via the account filter)

Soft-deletable entities use partial unique indexes (SQLite WHERE deleted_at IS NULL) instead of table-level UNIQUE constraints. This allows re-creation with the same name after soft-delete:

Index(
"uq_institutions_user_name",
"user_id", "name",
unique=True,
sqlite_where=text("deleted_at IS NULL"),
)

Account and Institution also support archiving via archived_at: datetime | None. Archived records remain visible (unlike soft-deleted) but are visually dimmed and can be filtered. Archiving cascades to child entities:

Action Method URL Pattern
Archive POST /{entity}/{id}/archive
Unarchive POST /{entity}/{id}/unarchive

A reconciled transaction is locked: its amount, type, date, account, status and deletion are refused with 409 and code transaction_locked. The detail carries transaction_id and the offending fields:

{"error": {"code": "transaction_locked", "message": "Transaction is reconciled", "detail": {"transaction_id": 42, "fields": ["amount"]}}}

Every transaction writer goes through app/services/transaction_lock.py; a new write path must call it too (an AST test fails on an unguarded writer). A single-row write rejects the whole request. A bulk operation that can refuse some rows applies the rest and returns an additive skipped: [{id, reason}] list on its result (reason is locked, transfer_leg, currency_mismatch or duplicate, plus split_parent, which is reserved and never emitted yet; see the list below), so existing clients that ignore the key keep working. Follow this pattern for any new bulk operation that may refuse some rows.


A transfer is two ordinary transactions that share an opaque transfer_group_id (see Database Architecture). The server mints the id: no request schema accepts it. Transaction responses carry it, together with transfer_peer (id, account_id and amount of the other leg), as additive keys.

Endpoint Purpose
POST /api/v1/budget/transfers Creates the expense leg and the income leg in one request (201); received_amount is required across currencies
POST /api/v1/budget/transfers/link Links two existing transactions (transaction_ids, exactly two)
POST /api/v1/budget/transactions/{id}/unlink Turns both legs back into ordinary transactions
GET /api/v1/budget/transfers/suggestions Likely pairs, computed on demand and read-only; paginated with limit/offset and X-Total-Count like any other list
GET /api/v1/budget/savings-rate One object, {currency, months: [{month, income, expenses, transferred, rate}], missing_rate_currencies}, bounded by the optional date_from/date_to; like the series endpoints it has no limit/offset, and rate is null for a month without income or with a currency that has no exchange rate (listed in missing_rate_currencies)

Errors are APIError responses with a specific code. An unknown or foreign account is a 404 account_not_found; the rest are 422:

Error code Raised when
transfer_same_account Both legs would sit on the same account (on create, or when an edit moves one leg onto the other’s account)
account_archived An archived account takes part in a transfer, or is the target of a bulk move (the whole batch is refused)
transfer_not_eligible Two transactions cannot be linked; detail.reason says why
transfer_not_linked Unlink is called on a transaction that is not a transfer leg
transfer_type_locked The type of a transfer leg is edited; unlink first
transfer_received_amount_required The accounts have different currencies and no received_amount was given
transfer_amount_mismatch The accounts share a currency and received_amount differs from amount

POST /transactions/batch-delete and POST /transactions/batch-update report every owned row they left alone in skipped: [{id, reason}], with these reasons:

  • locked: the row is reconciled and the request touches a locked field.
  • transfer_leg: the row is a transfer leg and the request touches its date, account or status; a batch delete skips both legs unless both are selected and mutable.
  • currency_mismatch: a move to another account would change the row’s currency.
  • duplicate: a move would collide with an existing (account, external_id), trashed rows included.
  • split_parent: reserved for split transactions; nothing emits it yet.

GET /api/v1/budget/transactions/{transaction_id}/similar lists the rows similar to one anchor transaction, so a client can offer one category for all of them. Similar means the same payee and transaction type, in any account and at any date, with an amount within the larger of 2.00 and 15% of the cluster’s median: the first band is centred on the anchor’s amount, the centre then moves once to the median of that band, and bands never chain. Only rows a merge accepts are listed (no trashed row, transfer leg, split parent, reconciled row or row without an account) and the anchor itself is left out. By default only uncategorized rows are listed; include_categorized=true also lists categorized rows with their category_id and subcategory_id, and either way they take part in the band. limit (1 to 250, default 50) and offset page the rows, ordered by date then id, newest first. The response carries payee_id, payee_name, transaction_type, median_amount (signed), total (the full count, whatever the page) and rows. An anchor that cannot cluster (no payee, a transfer leg, a split parent or no account) answers 200 with total 0 and no rows; an unknown, trashed or foreign anchor is a 404 transaction_not_found. The route only reads.

PATCH /api/v1/budget/budgets/{id} is deliberately stricter than before: monthly_limit must be positive, and an explicit null for category_id, monthly_limit, rollover_enabled or archived is a 422 that names the field (only subcategory_id may be null, which clears it). A client that sent 0 or null there now gets a 422.


Bank files are imported in two steps: a parse endpoint that writes nothing and returns a preview, then a commit endpoint that creates the rows the client sends back. All four endpoints sit under /api/v1/budget/transactions/import/, which is what gives them the larger import body limit (see below).

Endpoint Purpose
POST /api/v1/budget/transactions/import/parse Multipart upload of a .ofx, .qfx, .qif or .xml file (CAMT.052 or CAMT.053; the extension is matched case-insensitively). Returns one OfxImportPreview per statement; a CAMT file returns one per account and currency (statements of the same account and currency are merged), with source camt, no balance, and skipped_rows counting the booked entries it could not read or that are in another currency than their statement. QIF only reads three more form fields: qif_date_order (dmy by default, mdy or ymd), qif_number_format (1,234.56 by default, 1.234,56, 1 234,56, 1234.56 or 1234,56) and an optional account_id, used to flag the rows that account already holds (an unknown or foreign id is a 404 account_not_found); OFX, QFX and CAMT ignore all three
POST /api/v1/budget/transactions/import/commit OfxImportCommitRequest (ofx_account_id, account_id, rows, relink), shared by OFX, QFX, QIF and CAMT. A QIF file has no bank identifier, so its commit sends an empty ofx_account_id, which never links or changes the account’s stored identifier
POST /api/v1/budget/transactions/import/csv/parse Multipart upload of a .csv, .tsv or .txt file with a required account_id, an optional options and an optional preset. options is a JSON string holding a CsvImportMapping; preset is boursorama. The mapping used is options, else preset, else the account’s saved mapping, else an empty one. The response (CsvImportPreview) carries columns, the detected encoding and delimiter, the effective mapping, its mapping_source (request, saved, boursorama or none), mapping_missing_columns, the rows and their counts (ready, duplicates, errors, total). An empty or incomplete mapping still returns the columns
POST /api/v1/budget/transactions/import/csv/commit CsvImportCommitRequest: account_id, rows (at most 20000), an optional mapping and remember_mapping (false by default)

Both commit endpoints return the standard ImportSummary. The rows are created cleared, the user’s categorization rules that apply on import run on them, and a row whose external_id is already stored on the account is skipped and counted in duplicates_skipped, trashed rows included. A commit where every row is a duplicate is still a 200 with nothing created. ImportSummary.rules_timed_out lists {id, name} for each rule whose regular expression ran out of time during that run: the rule is skipped for the rest of the run and left unchanged, and the import itself succeeds. Rows still without a category after the rules take the category and subcategory of their payee’s default category; ImportSummary.categorized_from_payee counts the rows that did, only once the row was stored. That payee step runs after the rules, only on rows with no category and no subcategory, and reads the per-user suggest_category_from_payee switch. The Boursorama category import at /transactions/import-csv/ is a different feature and is not described here.

Each created row is linked to the payee whose name or alias matches its description after normalization (see Payees below), and the payee is created when none does. A row with a blank description gets no payee. The payee is set before the rules run, so a payee_id condition can match on import. Within one commit a text creates one payee, and a row that fails leaves no payee behind. The parse endpoints never write a payee: each row carries payee_name and payee_is_new, and each OFX/QIF/CAMT statement and the CSV preview carries new_payee_count, the distinct payees the commit would create (known duplicates, and CSV rows in error, are not counted). Commit ignores the echoed payee_name and payee_is_new and recomputes from the description. The Boursorama category import gives a transaction that has no payee the payee named by its CSV label; an existing payee is never replaced, and neither is one on a transaction whose label the user set by hand.

Limits shared by the importers:

  • File size: 10 MB, enforced while the upload streams in (413 ofx_file_too_large). The CSV endpoints reuse this code.
  • Body size: the import routes accept a request body of up to max_import_body_bytes (12 MB by default) while every other route stays at max_request_body_bytes (2 MB). The larger limit is chosen from the path, so a new import endpoint must live under /transactions/import/ (or /import/) to benefit from it.
  • Rows: 20000 per file and per commit (MAX_IMPORT_ROWS in app/services/import_parsing.py).

Text read from a file is kept verbatim. The importers never apply the export spreadsheet sanitiser, so a cell such as =SUM(A1) is stored as written.

OFX and QFX rows keep the bank’s FITID. CSV and QIF rows have none, so the server builds csv1:<32 hex>:<k> and qif1:<32 hex>:<k>. The hex part is a SHA-256 prefix over the ISO date, the canonical signed amount, the normalised description and the normalised reference; k is the number of earlier rows of the same file with the same hash. The guarantees:

  • Importing the same file twice yields the same ids, so the second import creates nothing.
  • Two identical rows in one file stay distinct (:0, :1), and a later export that overlaps an earlier one reproduces the ids of the shared rows even when the rows are reordered.
  • For CSV only the primary description column goes into the hash, so adding or removing an extra description column never changes an id.
  • Rows with a zero amount or an unreadable date or amount are never given an id: they are reported as row errors (CSV) or counted in skipped_rows (QIF).

CAMT rows keep the bank’s own entry reference (AcctSvcrRef, read per transaction detail, else per entry) as camt:<ref> when that reference is unique in the file; a row without one, or whose reference repeats in the file, gets camt1:<32 hex>:<k>, built like the CSV and QIF ids, so no two rows share an id and an id does not depend on the row’s position. Zero-amount CAMT entries are left out and get no id.

All are APIError responses. Statuses are 422 unless noted.

Error code Raised when
ofx_unsupported_format The OFX/QFX/QIF/CAMT parse endpoint gets an extension other than .ofx, .qfx, .qif or .xml
ofx_no_statement The file holds no statement (OFX/QFX), no Stmt/Rpt element (CAMT) or no transaction record (QIF)
ofx_unparseable_file An OFX or QFX file is corrupted or invalid
ofx_file_too_large The upload is over 10 MB (413), on the OFX, QFX, QIF, CAMT and CSV endpoints
ofx_account_conflict The OFX commit would link a bank identifier already linked to another account (409)
account_not_found The target account is unknown or belongs to someone else (404), on every endpoint above that takes an account_id
qif_unsupported_type The QIF file is an investment file (!Type:Invst)
qif_multiple_accounts The QIF file holds transactions under more than one !Account block
qif_too_many_rows The QIF file has more than 20000 transactions; detail.max is 20000
camt_unsafe_xml The CAMT file declares a DOCTYPE or entities; it is refused before anything is expanded
camt_malformed The file is not well-formed XML, has an unreadable encoding, nests deeper than 32 levels, holds more than 1000000 elements or more than 100 statements, or a statement has no usable account id
camt_not_camt The file is well-formed XML whose root is not a camt.052 or camt.053 Document
camt_too_many_rows The CAMT file has more than 20000 rows; detail.max is 20000
csv_unsupported_format The CSV parse endpoint gets an extension other than .csv, .tsv or .txt
csv_options_invalid options is not a valid CsvImportMapping JSON document
csv_unreadable The CSV cannot be decoded, or holds no readable record
csv_too_many_rows The CSV has more than 20000 rows; detail.max is 20000
csv_mapping_incomplete remember_mapping is true and the mapping does not map the date, the description and the amount (or both the inflow and outflow columns in split mode); nothing is created

A CSV row that cannot be read is not an error response: the preview row carries error (date, amount or other) and a fixed English error_detail, and the client leaves it out of the commit.

  • AccountOut.csv_import_mapping is read-only. It is not part of AccountCreate or AccountUpdate, and a value sent there is ignored. The commit endpoint is the only writer: with remember_mapping it stores the validated mapping on the account (replacing the previous one), in the same transaction as the rows, and it does so even when every row was a duplicate. The parse endpoint never writes. The column is SQL NULL while nothing is saved.
  • A saved mapping is applied by header first: a column keeps its stored index when its header still matches (accents, case and spacing ignored), otherwise the first unused column with an equal header is taken, otherwise the header is listed in mapping_missing_columns. A mapping sent in options is authoritative and is not re-resolved.
  • offer_rule_on_categorize is a boolean on PATCH /api/v1/auth/me (where false is a real change, not an absence) and on the user returned by GET /api/v1/auth/me. It only tells the UI whether to offer to create a rule after a manual categorization; the server does nothing with it.
  • missing_rates_dismissed is the sorted, comma-joined set of currencies the user dismissed the missing-rates notice for, on PATCH /api/v1/auth/me and on the user returned by GET /api/v1/auth/me (null when none). PATCH takes three-letter uppercase codes separated by commas in any order (stored sorted and unique) or an empty string to clear; anything else is a 422, and omitting the field leaves it unchanged. The six exchange-rate mutation routes (create, update, delete, refresh, backfill-snapshots, fetch) clear it; the nightly refresh does not.
  • setup_checklist_hidden is a boolean on PATCH /api/v1/auth/me (where false is a real change, not an absence) and on the user returned by GET /api/v1/auth/me; it only tells the web app to stop showing the Dashboard setup checklist.
  • suggest_category_from_payee is a boolean on PATCH /api/v1/auth/me (where false is a real change, not an absence) and on the user returned by GET /api/v1/auth/me; it is the per-user switch of the payee category suggestion and defaults to true.

The condition tree of a categorization rule (conditions on POST and PATCH /api/v1/categorization-rules and on POST /api/v1/categorization-rules/preview) is a group of leaves and nested groups. A leaf is {kind: "leaf", field, op, value} (value_to for between).

  • Operators: contains, starts_with, equals, gt, lt, gte, lte, between, not_equals, not_contains, one_of and matches. one_of, not_contains and matches only apply to the description and suggested_label fields (one_of also applies to payee_id).
  • List value: one_of takes a list of 1 to 20 non-blank strings of at most 200 characters (each is trimmed, and the comparison ignores case). A list with any other operator is a 422.
  • Payee: the payee_id field takes only equals with an integer payee id, or one_of with a list of payee ids written as digit strings (for example ["7", "9"]). Any other operator, a non-integer equals value or a non-digit one_of value is a 422. A transaction without a payee never matches. Ids are not checked for ownership when a rule is saved, so a rule naming a deleted or foreign payee never matches.
  • Regular expression: matches takes a non-empty pattern of at most 200 characters. The pattern is searched, not fully matched, and every search is capped at 50 ms. A text over 1000 characters is cut before the search.
  • Negation: not_equals and not_contains are the exact negation of equals and contains on the same data, so an empty field satisfies them.
  • Caps: a saved or previewed rule holds at most 30 nodes (every group, the root included, and every leaf counts for one) and nests at most 4 levels of groups (the root is level 1). The caps are checked when a rule is saved or previewed, never when a stored rule is evaluated, so a rule saved before the caps existed keeps matching.
  • Save-time probe: creating or updating a rule compiles each matches pattern and runs it against hostile sample texts, within 2 seconds in total. A preview only compiles. A pattern that fails either step is refused with a 422 before anything is stored.
  • Run-time timeout: when a pattern still exceeds 50 ms on real data, an import skips that rule for the rest of the run (rules_timed_out), POST /api/v1/categorization-rules/preview stops the scan, returns the rows found so far and sets the response header X-Rule-Timed-Out: true (absent otherwise), and POST /api/v1/categorization-rules/{id}/apply stops and returns RuleApplyResult.timed_out = true with the counts reached. A timeout never disables the stored rule.
  • Suggested label: action_suggested_label (at most 255 characters, trimmed, blank stored as null) is the label a matching rule writes. An explicit null on PATCH clears it.
Error code Raised when
rule_too_many_nodes The tree has more than 30 nodes; detail is {nodes, max_nodes}
rule_too_deep Groups nest deeper than 4 levels; detail is {depth, max_depth}. The node count is checked first
rule_regex_invalid A matches pattern does not compile or is refused by the size guards; detail is {path, reason}
rule_regex_timeout A matches pattern is too slow in the save-time probe; detail is {path, timeout_ms}

detail.path is the list of child indexes leading from the root group to the offending leaf, so a client can highlight the exact condition. reason is the regex library’s own message and never contains the pattern or any text.

A payee is the merchant or counterparty a transaction points at. GET, POST, PATCH /{id} and DELETE /{id} on /api/v1/payees serve the caller’s own payees only: the id of another user’s payee is a 404 payee_not_found on every route.

  • Normalization: a name is trimmed, every run of whitespace (tabs and non-breaking spaces included) collapses to one space, and it is cut to 255 characters. Two names are the same payee when they match ignoring case (Unicode case folding), so Épicerie and épicerie are one payee. Migration 120 groups existing transactions with the same rule.
  • One namespace: a payee name and an alias never share a normalized key within a user. Creating a payee, or renaming one, to a name that is another payee’s name or alias is a 409 payee_already_exists.
  • Rename: renaming keeps the old name as an alias of the same payee, so text that used the old name still resolves to it. A rename that only changes case or spacing stores no alias, and renaming a payee to one of its own aliases removes that alias.
  • List: limit (default 50, at most 250), offset, search (names and aliases, at most 100 characters), ids (repeatable, at most 100; only the caller’s payees come back), sort_by (name or usage) and sort_dir (asc or desc). The total is in X-Total-Count. Each item carries its aliases, its usage_count and its planned_transaction_count.
  • Usage and delete: usage_count counts every transaction that uses the payee, trashed ones included, and planned_transaction_count counts the planned transactions that use it, trashed ones included. DELETE of a payee that any transaction or planned transaction uses is a 409 payee_in_use with detail.usage_count and detail.planned_transaction_count; an unused payee is deleted together with its aliases (204).
  • Merge: GET /api/v1/payees/merge-preview?payee_ids= (repeatable, 1 to 100) returns {transaction_count, planned_transaction_count}, the transactions and planned transactions that merging those payees would repoint, trashed ones included. POST /api/v1/payees/merge with {survivor_payee_id, merged_payee_ids} (1 to 100 ids) repoints every one of them to the survivor and returns both counts with deleted_payee_ids. The survivor is dropped from merged_payee_ids; when nothing is left the answer is a 422 payee_merge_invalid, and an id of another user is a 404 payee_not_found with nothing changed. The merged names and their aliases become aliases of the survivor, a survivor without a default category takes the defaults of the first merged payee that has one, rule payee conditions naming a merged payee name the survivor, and the merged payees are deleted. The audit event is payee.merged.
  • Category suggestion: GET /api/v1/payees/{payee_id}/category-suggestion returns {category_id, subcategory_id, source}, where source is default (the payee’s own default category, when it still validates) or history (the category on at least 60% of the payee’s categorized live transactions, with at least 3 of them; transfers, trashed rows and split parents are not counted). The body is all null when nothing is suggested or when the user’s suggest_category_from_payee switch is off; an id of another user is a 404 payee_not_found, switch or not. The route only reads: no audit event, nothing is changed.
  • Default category: default_category_id and default_subcategory_id are stored and returned. The importers apply them to rows still uncategorized after the rules (ImportSummary.categorized_from_payee), and the category suggestion route above serves them; manual transaction creation does not apply them. A category or subcategory the caller does not own is a 404 (category_not_found, subcategory_not_found), a subcategory of another category is a 422 category_subcategory_mismatch, an explicit null category clears both, and changing the category without naming a subcategory drops the old subcategory.
  • Transactions: every transaction returned by GET /api/v1/budget/transactions carries payee_id and a nested payee ({id, name}), both null when the transaction has no payee.
  • Set and filter: POST and PATCH /api/v1/budget/transactions accept payee_id. A payee of another user is a 404 payee_not_found and nothing is written, an explicit null clears the payee, and a non-null payee on a transfer leg is a 422 payee_on_transfer (clearing it, or sending the leg’s current payee back, is fine). A reconciled transaction keeps an editable payee. The list, ids, summary and CSV/ODS/XLSX export endpoints take a repeatable payee_id query parameter (at most 100); a payee of another user matches no row.
  • Planned transactions: POST and PATCH /api/v1/planned-transactions accept payee_id (a payee of another user is a 404 payee_not_found, an explicit null clears it, omitting it leaves it alone); the response carries payee_id and a nested payee ({id, name}). Every transaction a planned item generates is created with that payee; changing the payee later never touches transactions already posted. A backup carries it as planned_transactions[].payee_name.

Monetary values are stored as a Numeric column with fixed precision — most tables (PlannedTransaction, Budget, RecurringSnapshot, Snapshot, and similar) use Numeric(12, 2) — and are always serialized as strings in JSON responses.

{
"balance": "12500.00"
}

This precision isn’t universal: app/models/investment.py intentionally widens it for buy-price accuracy — Holding.quantity is Numeric(24, 8), and cost_basis/current_price are Numeric(20, 8) — and app/models/delta_cache.py’s delta/total use Numeric(14, 2). Check the specific model before assuming (12, 2).

Request schemas validate money fields against the column they feed through the Money, RateDecimal, QuantityDecimal and PriceDecimal types in app/utils/money.py: NaN, Infinity, and any value that does not fit the column (for Numeric(12, 2), an absolute value of 10^10 or more) are rejected with a 422, and extra decimal places are rounded half-up to the column scale. The import paths (spreadsheet commit, backup restore) apply the same rule through _validated_money in app/services/import_service.py and report a bad amount as a per-row ImportError instead of failing the request.

Never use floating-point numbers for monetary values, regardless of the column’s precision. JSON numbers are parsed as floats by most clients, leading to precision loss. Strings preserve exact decimal representation — this rule is universal even where the stored precision isn’t.


A POST that is not naturally idempotent may accept an optional Idempotency-Key header (1 to 64 characters). A repeat with the same key is a replay: it returns the current state and records nothing again. Today only POST /api/v1/envelopes/{id}/pay uses it (the web app sends the envelope’s due date, so a double click cannot advance a recurring envelope twice).