Backend Architecture
The backend is a FastAPI application running on uvicorn with async SQLAlchemy 2.0. It provides a REST API for net-worth tracking, authentication, data import/export, budgeting, and investment management.
App Structure
Section titled “App Structure”backend/app/├── main.py # App factory, lifespan, router registration, middleware├── config.py # Pydantic BaseSettings (loads from .env)├── database.py # Async SQLAlchemy engine, session factory, get_db├── dependencies.py # get_current_user (dual auth: OIDC + local JWT + API token)├── errors.py # Structured error responses (APIError, handlers)├── i18n.py # Backend translations (fr/en/es/de/it)├── worker.py # arq worker settings (optional Redis-based background tasks)├── middleware/ # Request id, metrics, security headers, rate limiting, body limit, CSRF, audit logging, FX flags├── models/ # 40 model files (47 mapped classes, 50 tables) + base/mixins/enums├── routers/ # 42 API routers├── schemas/ # Pydantic request/response schemas├── services/ # 91 service modules and the backup_import package (business logic)└── utils/ # JWT, cookies, OIDC client helpersCore Modules
Section titled “Core Modules”| File | Responsibility |
|---|---|
main.py |
Creates the FastAPI app, registers the exception handlers and nine middleware (Metrics outermost → … → CORS innermost, see Middleware), includes all routers, and starts up to 14 lifespan background tasks. Also closes the shared OIDC HTTP client on shutdown. |
config.py |
Settings(BaseSettings) loads from .env via Pydantic Settings; all configuration in one place |
database.py |
Creates the async engine (create_async_engine), session factory (async_sessionmaker), and get_db dependency (always commits on success, rolls back on exception); sets SQLite WAL/FK PRAGMAs via event listener |
dependencies.py |
get_current_user validates JWT from cookie or API token from Authorization: Bearer ptr_... header; supports local (HS256) and OIDC (RS256) tokens; handles token refresh including OIDC refresh token rotation; imports validate_api_token and upsert_from_claims at top level |
errors.py |
APIError exception class and four handlers (api_error_handler, http_exception_handler, validation_exception_handler, unhandled_exception_handler) producing {"error": {"code", "message", "detail"?}} |
i18n.py |
translate(key, locale, **kwargs) for backend error messages; supports fr/en/es/de/it, falls back to fr |
worker.py |
arq WorkerSettings with 10 cron jobs; used when REDIS_URL is set; otherwise lifespan tasks handle scheduling |
Routers
Section titled “Routers”42 routers registered in main.py. The table below lists each router, its URL prefix, and the endpoints it exposes.
| Router | Prefix | Endpoints |
|---|---|---|
health.py |
(none) | GET /health, GET /health/db |
metrics.py |
(none) | GET /metrics (Prometheus text format; bearer METRICS_TOKEN, 404 when unset) |
auth.py |
(none) | GET /auth/oidc/start, GET /auth/callback, POST /auth/login, POST /auth/logout, POST /auth/register, POST /auth/login/token, POST /auth/mobile/exchange, `GET |
sessions.py |
/api/v1/auth/sessions |
GET (paginated, X-Total-Count), DELETE /{id}, POST /revoke-others; the caller’s own browser sessions |
accounts.py |
/api/v1/accounts |
CRUD, reorder, restore, archive, permanent delete |
admin_users.py |
/api/v1/admin/users |
Admin-only user management: list, lock/unlock, role changes, invites (create, list, revoke, purge), email change, MFA disable, audit view and export |
alerts.py |
/api/v1/alerts, /api/v1/alert-rules |
Paginated visible notifications fired by the alert check (newest first, read and unread), their unread count, and read, dismiss and read-all actions; GET and PUT of the three alert rules (defaults filled in; PUT re-evaluates the user); GET and PUT /api/v1/notification-preferences, the channel of each notification type |
api_tokens.py |
/api/v1/tokens |
CRUD (ptr_ prefixed tokens) |
audit_logs.py |
/api/v1/audit-logs |
Paginated query |
planned_transactions.py |
/api/v1/planned-transactions |
CRUD, due/overdue checks, reminders (merged former bills + recurring transactions), payee_id (set or clear) on create and update with the payee returned as payee, /subscriptions (active repeating expenses with yearly amounts in the base currency and price changes) |
budget.py |
/api/v1/budget |
Budgets CRUD, PUT/DELETE /budgets/{budget_id}/month-limits/{month}, monthly/range summary, analytics, spendable, savings rate (GET /savings-rate) |
categories.py |
/api/v1/categories |
CRUD, reorder |
categorization_rules.py |
/api/v1/categorization-rules |
CRUD, rule-based auto-categorization of transactions; size caps and a regex probe on save (422 rule_too_many_nodes, rule_too_deep, rule_regex_invalid, rule_regex_timeout); preview reports a regex timeout in the X-Rule-Timed-Out header |
envelopes.py |
/api/v1/envelopes |
Envelope CRUD, contributions, mark paid, free-to-use figure; GET /payday-plan lists, per pay schedule that at least one envelope or goal uses (the default schedule first, then by next payday), the next payday, the per-payday amount of each envelope and goal and their total |
funding_schedules.py |
/api/v1/funding-schedules |
Pay schedules: create, list, read, edit, delete (the default schedule cannot be deleted until another default is chosen or it is cleared, and a schedule that envelopes or goals still pick is refused with 409 funding_schedule_in_use and the number of items using it) |
goal_templates.py |
/api/v1/goal-templates |
Contribution templates: create, list, read, edit, delete (a template that goals still use is refused with 409 goal_template_in_use and the number of goals using it) |
chart_annotations.py |
/api/v1/chart-annotations |
CRUD for user annotations overlaid on charts |
charts.py |
/api/v1/charts |
net-worth, account-trends, stacked-by-type, stacked-by-category, statistics, delta-heatmap, waterfall |
data_import.py |
/api/v1/import |
parse (CSV/ODS/XLSX/JSON/OFX), commit |
exchange_rates.py |
/api/v1/exchange-rates |
CRUD, filterable/sortable paginated list, /refresh (auto-FX), /fetch (single-pair historical) |
export.py |
/api/v1/export |
CSV, JSON, ODS, XLSX, backup |
institutions.py |
/api/v1/institutions |
CRUD, aggregated balances, soft-delete with cascade, archive/unarchive, restore, permanent delete, empty trash |
investment.py |
/api/v1/investments |
Holdings + dividends CRUD (a dividend is edited with PATCH /holdings/{holding_id}/dividends/{dividend_id}), portfolio summary, price refresh |
market_data.py |
/api/v1/market-data |
Ticker lookup/price fetch (Yahoo Finance) used by investment holdings |
net_worth_thresholds.py |
/api/v1/net-worth-thresholds |
CRUD and projected dates |
reconciliations.py |
/api/v1/budget |
Reconcile an account against a statement: POST /reconciliations/preview, POST /reconciliations, POST /reconciliations/{id}/undo, GET /reconciliations |
recurring_snapshots.py |
/api/v1/recurring-snapshots |
CRUD |
reports.py |
/api/v1/reports |
GET/PATCH /settings (the saved assumptions), GET /financial-independence, GET /cash-flow, GET /simulation (the seeded retirement simulation, optional current_age, retirement_age, retirement_length, volatility and seed query values over the saved settings; 422 simulation_age_required, invalid_retirement_age; 503 simulation_busy when both run slots stay taken for five seconds), POST /run (runs a report definition and stores nothing; 422 report_too_large, report_too_many_intervals); all read-only apart from the settings PATCH |
savings_goals.py |
/api/v1/savings-goals |
CRUD, account linking |
snapshots.py |
/api/v1/snapshots |
CRUD, counts, restore, batch delete by date |
saved_filters.py |
/api/v1/saved-filters |
List (paginated), create, rename, delete |
saved_reports.py |
/api/v1/saved-reports |
List (paginated), create, update (rename and/or definition), delete; 409 saved_report_name_taken and saved_report_limit, 404 saved_report_not_found |
dashboards.py |
/api/v1/dashboards |
List (paginated), create, POST /initialize (creates the first dashboard, idempotent), PUT /reorder, update (rename and/or layout), delete; 409 dashboard_limit, dashboard_last and dashboard_card_conflict, 422 dashboard_order_invalid, 404 dashboard_not_found |
subcategories.py |
/api/v1/subcategories |
CRUD, reorder |
system.py |
(none) | GET /api/v1/system/info |
tags.py |
/api/v1/tags |
CRUD, tag assignment to transactions |
payees.py |
/api/v1/payees |
Paginated list (X-Total-Count) with search over names and aliases, ids lookup and name/usage sort, create, rename keeping the old name as an alias, default category, delete unused, GET /{id}/category-suggestion, merge preview and merge |
transaction_trash.py |
/api/v1/budget |
Transaction trash: GET /transactions/deleted/list, POST /transactions/{id}/restore, DELETE /transactions/{id}/permanent, DELETE /transactions/deleted/empty |
transfers.py |
/api/v1/budget |
Transfers between two accounts: POST /transfers (create a linked pair), POST /transfers/link (link two existing transactions), POST /transactions/{id}/unlink, GET /transfers/suggestions (paginated, read-only) |
transactions.py |
/api/v1/budget |
Transactions CRUD, filtering/sorting, export (CSV/ODS/XLSX), batch delete/update, bank-file import (OFX, QFX, QIF, CAMT and generic CSV with a column mapping: parse and commit pairs under /transactions/import/), Boursorama CSV category import |
transaction_splits.py |
/api/v1/budget |
PUT /transactions/{id}/splits (replace the lines), DELETE /transactions/{id}/splits (remove them), POST /transactions/merge (merge two duplicates) |
webhooks.py |
/api/v1/webhooks |
CRUD (HMAC-signed) |
Database Models
Section titled “Database Models”40 model files in app/models/ (47 mapped model classes plus 3 association tables — several files, e.g. budget.py and investment.py, define more than one class), all registered with Base.metadata via app/models/__init__.py for Alembic autogenerate.
| Model | File | Table | Purpose |
|---|---|---|---|
User |
user.py |
users |
User with integer PK, OIDC sub, local auth, locale, base currency |
Account |
account.py |
accounts |
Financial account with soft-delete, type, currency, liquidity horizon, on_budget (an off-budget account is left out of budgets, analytics and the spendable pool) and exclude_from_net_worth (the account stays visible but is left out of every net worth total); csv_import_mapping holds the CSV column mapping last confirmed for the account (SQL NULL when unset) |
Snapshot |
snapshot.py |
snapshots |
Balance record per account per date; unique index on (account_id, date) where deleted_at IS NULL |
Category |
category.py |
categories |
User-owned spending category (with subcategories) used by transactions, budgets, rules and splits, with colour, sort order and exclude_from_spending |
Institution |
institution.py |
institutions |
User-owned financial institution grouping with soft-delete (deleted_at) and archive (archived_at); cascade soft-delete/restore to linked accounts and snapshots; partial unique index on (user_id, name) where deleted_at IS NULL |
RecurringSnapshot |
recurring_snapshot.py |
recurring_snapshots |
Scheduled snapshot creation rules (weekly/monthly) |
Envelope, EnvelopeContribution |
envelope.py |
envelopes, envelope_contributions |
Money set aside for a non-monthly expense, and the amounts added to it |
FundingSchedule |
funding_schedule.py |
funding_schedules |
A user’s pay schedule: a name and a cadence (dates only, no amount); at most one default per user, names unique per user without regard to case |
GoalTemplate |
goal_template.py |
goal_templates |
A user’s reusable contribution: a fixed amount per paycheck or per month; names unique per user without regard to case; it never moves money |
SavingsGoal |
savings_goal.py |
savings_goals |
Savings target with optional deadline and linked accounts |
savings_goal_accounts |
savings_goal.py |
savings_goal_accounts |
M2M association table (SavingsGoal ↔ Account) |
Budget |
budget.py |
budgets |
Monthly spending limit per category with rollover |
BudgetMonthLimit |
budget.py |
budget_month_limits |
A budget’s own limit for one month; a month without a row uses the budget’s default limit |
Transaction |
budget.py |
transactions |
Income/expense transaction linked to account; status (pending, cleared, reconciled), deleted_at (trash), reconcile_id (the reconciliation that locked it), transfer_group_id (opaque id shared by the two legs of a transfer, NULL otherwise) and payee_id (the transaction’s payee, NULL when its text names nobody) |
Reconciliation |
reconciliation.py |
reconciliations |
One account reconciled against a statement (statement date and balance, the snapshot it wrote or replaced, undone_at) |
TransactionSplit |
transaction_split.py |
transaction_splits |
One category line of a split transaction (category, optional subcategory, signed amount, position); removed with its parent |
PlannedTransaction |
planned_transaction.py |
planned_transactions |
Recurring/scheduled transaction with due date, reminders, overdue detection (merged former Bill + RecurringTransaction); weekend_shift and skipped_dates hold the weekend option and the skipped anchors |
PlannedSuggestionDismissal |
planned_suggestion_dismissal.py |
planned_suggestion_dismissals |
A suggested planned transaction the user dismissed, keyed by account, transaction type and description key; removed with its account |
CategorizationRule |
categorization_rule.py |
categorization_rules |
Rule-based auto-categorization of transactions, with optional category, subcategory, tag and suggested-label actions |
ChartAnnotation |
chart_annotation.py |
chart_annotations |
User annotation overlaid on chart timelines |
Tag |
tag.py |
tags |
User-owned tag applicable to transactions |
Payee, PayeeAlias |
payee.py |
payees, payee_aliases |
A user’s payee (merchant or counterparty a transaction points at, unique per user on its case-folded name_key, optional default category and subcategory) and the other spellings that resolve to it (alias_key unique per user) |
SavedFilter |
saved_filter.py |
saved_filters |
Named transaction filter set of one user, stored as JSON; unique per (user_id, name) |
ReportSettings |
report_settings.py |
report_settings |
One row per user: investable accounts (JSON list, no foreign key), withdrawal rate, real return and an optional yearly spending override; no row means the defaults |
SavedReport |
saved_report.py |
saved_reports |
Named report definition of one user, stored as JSON; unique per (user_id, name) |
Dashboard |
dashboard.py |
dashboards |
Named dashboard of one user: tab order and card layout stored as JSON; at most 8 per user |
NetWorthThreshold |
net_worth_threshold.py |
net_worth_thresholds |
Net-worth threshold; is_past mirrors the latest alert check and is written by it |
Holding |
investment.py |
holdings |
Investment holding (ticker, quantity, cost basis, price) |
Dividend |
investment.py |
dividends |
Dividend payment for a holding |
ExchangeRate |
exchange_rate.py |
exchange_rates |
Manual or auto-fetched FX rate per (from, to, date) |
ApiToken |
api_token.py |
api_tokens |
Long-lived API token (SHA-256 hashed, ptr_ prefix) |
Webhook |
webhook.py |
webhooks |
Webhook endpoint config with HMAC secret |
AuditLog |
audit_log.py |
audit_logs |
Persisted audit event (method, path, status, IP, duration) |
AlertRule, Alert, NotificationPreference |
alert.py |
alert_rules, alerts, notification_preferences |
A user’s saved alert rule settings, the alerts those rules fired (one row per occurrence, dedup_key unique per user, with the user’s read and dismissed marks), and a user’s channel per notification type |
RevokedToken |
revoked_token.py |
revoked_tokens |
JWT blacklist entry (jti, user_sub, expires_at) |
TotpRecoveryCode |
totp_recovery_code.py |
totp_recovery_codes |
One-time two-factor recovery code: plain lookup id, Argon2 hash of the secret half, used_at; unique per (user_id, lookup_id) |
UserSession |
user_session.py |
user_sessions |
Tracked browser session: random sid, expires_at, revoked_at, last_seen_at (written at refresh rotation), coarse browser/OS label and /24 or /48 network |
DeltaCache |
delta_cache.py |
delta_cache |
Precomputed daily net-worth delta for heatmap |
UserInvite |
user_invite.py |
user_invites |
Pending admin-issued invite token for new user registration |
PasswordResetToken |
password_reset_token.py |
password_reset_tokens |
Single-use token for local-account password reset |
EmailChangeToken |
email_change_token.py |
email_change_tokens |
Single-use token for confirming an email address change |
TransactionDuplicateDismissal |
transaction_duplicate_dismissal.py |
transaction_duplicate_dismissals |
A pair of transactions the user declared “not duplicates”, stored ordered (a < b), unique per user; cascades on permanent delete |
WebhookDelivery |
webhook_delivery.py |
webhook_deliveries |
Per-attempt webhook delivery record with retry/backoff state |
Service Modules
Section titled “Service Modules”91 service modules in app/services/ containing business logic, separated from routers.
| Service | File | Responsibility |
|---|---|---|
account_service |
account_service.py |
Account CRUD, soft-delete, reorder, archive/restore |
account_closing_service |
account_closing_service.py |
Close an account inside the request transaction: a transfer of the remaining balance, a snapshot for each account on the transfer date, then the archive; every refusal comes before the first write |
account_validation |
account_validation.py |
Shared account input validation helpers |
admin_user_service |
admin_user_service.py |
Admin user management: list, lock/unlock, role changes |
alert_service |
alert_service.py |
Evaluates each user’s alert rules, net worth thresholds and snapshot reminders in the worker, stores one alert per occurrence, posts alert.fired to the user’s webhooks and emails the types set to email or both after commit, and serves the read and dismissed state and the channel filter behind the bell |
api_token_service |
api_token_service.py |
Token generation (ptr_ prefix), hashing, validation |
audit_log_service |
audit_log_service.py |
Audit log creation, paginated query, cleanup |
auth_service |
auth_service.py |
Local login, registration, password change, failed-login lockout |
backup_service |
backup_service.py |
SQLite sqlite3.backup() snapshot creation |
balance_forecast_service |
balance_forecast_service.py |
Day-by-day liquid balance forecast from the opening balance and planned transactions (read-only); get_what_if_forecast replays it with up to five hypothetical one-off, weekly or monthly items and returns the base and simulated series plus the change in lowest and end-of-horizon balance, storing nothing |
payday_plan_service |
payday_plan_service.py |
Builds the payday plan, grouped by pay schedule, from the envelopes and goals and the schedule each uses (its pick, else the default) (read-only) |
payday |
payday.py |
Resolves the pay schedule an item uses (its pick, else the user’s default), counts the paydays of a schedule between two dates, honouring the weekend shift, and gives the next payday |
funding_schedule_service |
funding_schedule_service.py |
Creates, lists, reads, edits and deletes a user’s pay schedules, works out each one’s next payday and counts the envelopes and goals that pick it, refusing a delete while any do |
goal_template_service |
goal_template_service.py |
Stores a user’s contribution templates, counts the goals that use each, and projects a goal’s completion date from a template and the goal’s pay schedule on every read (nothing is stored) |
bank_formats |
bank_formats.py |
Bank-specific CSV format detection and parsing |
planned_schedule |
planned_schedule.py |
Pure recurrence rules for planned transactions, shared by the auto-post sweep, manual payment and the forecast. A date is an anchor and the weekend shift only maps it to the effective date it is posted on. The sweep pre-filters on anchors widened by SHIFT_REACH_DAYS (two days), then filters exactly on effective dates. Stepping over a skipped anchor never counts toward max_occurrences, and a pointer that lands on a skipped anchor advances lazily, without posting |
planned_transaction_service |
planned_transaction_service.py |
Planned transaction CRUD, due/overdue checks, reminders |
planned_suggestion_service |
planned_suggestion_service.py |
Suggested planned transactions, computed on demand and read-only: ledger rows are grouped by account, type and description, a weekly, monthly, quarterly or yearly gap pattern is detected with named thresholds (GAP_BANDS_DAYS, SUGGESTION_MIN_OCCURRENCES, SUGGESTION_AMOUNT_TOLERANCE, a 24-month lookback), and patterns a planned item already covers or the user dismissed are left out. Dismissals are the only thing stored |
subscription_service |
subscription_service.py |
Yearly normalisation, base-currency conversion and price-change detection for the Subscriptions view |
budget_service |
budget_service.py |
Budget CRUD, rollover, analytics, monthly/range summary |
cash_flow_service |
cash_flow_service.py |
The cash flow graph of a period (read-only): flow rows grouped by category and converted to the base currency, income sources (the top 5, the rest as Other) into one hub that flows out to the spending categories (the top 8, the rest as Other) and, for a surplus, a Savings node; a shortfall is a Deficit node on the source side. Synthetic nodes carry a kind and no label, so the client translates them |
camt_import_service |
camt_import_service.py |
CAMT.052/053 reader: defusedxml with any DOCTYPE refused, depth, element and statement bounds, booked entries only (unreadable and foreign-currency entries counted as skipped), rows and external ids, one preview per account and currency through previews_from_statements |
categorization_rule_service |
categorization_rule_service.py |
Rule-based auto-categorization CRUD and matching (including the RuleRun that skips a slow regex rule for the rest of one import) |
category_seed_data |
category_seed_data.py |
Default category/subcategory seed data for new users |
category_service |
category_service.py |
Category CRUD, reorder |
category_validation |
category_validation.py |
Shared category input validation helpers |
chart_annotation_service |
chart_annotation_service.py |
Chart annotation CRUD |
chart_service |
chart_service.py |
Chart data aggregation, delta cache refresh |
csv_category_import_service |
csv_category_import_service.py |
Bulk category import from CSV |
csv_import_service |
csv_import_service.py |
Generic bank CSV import: preview under a column mapping (saved, preset or requested) and commit through the shared bank-file core; remembers the mapping on the account |
custom_css_service |
custom_css_service.py |
Validation of a user’s custom theme, a pure allow-list with no database access: the colour tokens every skin defines plus --radius are the only properties, each value must match the grammar of its property (a colour, or a length for --radius), the length is checked first (MAX_LENGTH, 8000 characters), and only the canonical re-serialised --name: value; text is stored, never the raw input. A refusal is a 422 custom_css_invalid whose detail carries the line, the property and the reason, with a message in the user’s locale. Used by the profile update, by backup restore and, for its constants, by GET /api/v1/auth/custom-css/rules |
email_service |
email_service.py |
Transactional email sending (invites, password reset, email change) via aiosmtplib |
exchange_rate_service |
exchange_rate_service.py |
FX rate CRUD, auto-fetch from frankfurter.app |
export_service |
export_service.py |
CSV/JSON/ODS/XLSX/backup export |
fire_service |
fire_service.py |
The financial independence projection in today’s money (read-only): the target is annual spending divided by the withdrawal rate, and the balance grows with the real return and the average monthly savings of the completed months of the last year, for at most 600 months. The accounts come from the request, then the saved settings, then the default (the live, off-budget accounts that are not liabilities); the starting balance is the latest snapshot of each account in the base currency, and one without a rate is left out and counted The projection returns no_history, then no_accounts, then no_spending (a yearly spending of zero, whose target of zero any balance would reach) before it projects anything. |
import_parsing |
import_parsing.py |
Standard-library helpers shared by the CSV, QIF and CAMT importers: strict amount, date and decoding parsers, stable synthetic external ids, text normalisation, MAX_IMPORT_ROWS |
import_service |
import_service.py |
File parsing (CSV/ODS/XLSX/JSON), commit |
institution_service |
institution_service.py |
Institution CRUD, soft-delete with cascade to accounts/snapshots, restore with cascade, archive/unarchive with cascade to accounts, permanent delete, empty trash, aggregated balances |
investment_service |
investment_service.py |
Holdings/dividends CRUD, portfolio summary, daily price refresh |
invite_service |
invite_service.py |
Admin-issued user invite creation and acceptance |
ledger_scope |
ledger_scope.py |
Row scopes for every transaction read (five: live, cash, flow, saving, guard) and the statement builders services use instead of querying transactions directly. live() excludes trashed rows; cash() keeps everything live (balances see transfer legs and off-budget accounts); flow() also drops transfer legs and rows of off-budget accounts, so budgets and analytics never count a transfer; saving() selects the on-budget leg of a transfer whose other leg sits on an off-budget account (savings rate only); guard() includes trashed rows. transfer_candidates is the leg query behind the transfer suggestions. select_flow_lines is select_flow with the split lines expanded, for category readers only: a parent with lines yields one row per line, any other row yields itself, and the category and amount are read through the LINE_CATEGORY_ID, LINE_SUBCATEGORY_ID and LINE_AMOUNT expressions. Totals and the cash scope stay on the parent row, because the join would count a parent once per line |
net_worth_scope |
net_worth_scope.py |
The one predicate saying which accounts count toward net worth: counted(), true for an account that is not excluded from net worth. Every net worth total applies it (the net worth and adjusted series, stacked by type, waterfall, concentration, liquidity, grid totals, thresholds, the delta heatmap, spendable, the pooled balance forecast and the default FIRE set); per-account views, latest-per-account, alerts, reminders, savings goals and portfolio views never do. tests/test_net_worth_scope_guard.py fails when a snapshot read or a latest-per-account call is not classified, or when a total does not use it, and tests/test_net_worth_exclusion.py proves each total equals the same user without the excluded accounts |
liquid_balance |
liquid_balance.py |
Opening balance of the liquid account pool (latest snapshot plus later ledger rows), default pool and stale-snapshot threshold |
market_data_service |
market_data_service.py |
Ticker lookup and price fetching (Yahoo Finance) |
merge_service |
merge_service.py |
Merge two duplicate transactions: the kept one is filled from the other’s empty fields, tags are united and the other goes to the trash; reconciled rows, transfer legs and split parents refuse (409 merge_blocked) |
net_worth_threshold_service |
net_worth_threshold_service.py |
Threshold CRUD and projected dates (crossings are notified by alert_service) |
ofx_import_service |
ofx_import_service.py |
OFX and QFX file parsing via ofxtools, statement preview and commit; previews_from_statements builds the per-account preview that the CAMT reader shares |
recovery_code_service |
recovery_code_service.py |
One-time two-factor recovery codes: generation (ten codes, the secret half stored as an Argon2 hash), normalisation, consumption with exactly one verify per attempt (a dummy hash when nothing matches) and an atomic single use, status and deletion |
recurring_snapshot_service |
recurring_snapshot_service.py |
Rule CRUD and the sweep that reschedules due rules (the reminder itself is raised by alert_service) |
report_service |
report_service.py |
Scheduled net-worth report generation and emailing |
report_builder_service |
report_builder_service.py |
Runs a report definition and persists nothing: ledger rows are grouped by one or two dimensions (category, account, time interval) and measured (expenses, income, net, count). An interval is always the x axis; series keep the top 7 and a non-interval x axis the top 50, the rest folded into Other. Dimensions and measures are closed enums mapped to Python functions, never SQL built from strings |
report_rows |
report_rows.py |
The one path from ledger rows to converted figures for the reports. It reads only through the ledger scopes: select_flow, so a transfer leg or an off-budget account never counts, and select_flow_lines only when a category dimension is grouped, so a split transaction counts in each line’s own category. The statement never joins accounts or categories itself. Each (day, account, category, type) group is converted with the rate of its own date; a group with no rate is left out and counted (excluded_transactions, missing_rate_currencies). Interval buckets and the folding into Other are computed in Python, and two caps refuse a report: 100,000 grouped rows (report_too_large) and 367 interval buckets (report_too_many_intervals) |
report_settings_service |
report_settings_service.py |
Report settings: the defaults without a row, a read that never writes, saved account ids filtered to the caller’s own live, non-archived, non-liability accounts on every read and write, and a first save inside a savepoint so two concurrent first saves leave one row |
monte_carlo_service |
monte_carlo_service.py |
The retirement simulation, in today’s money and deterministic for a seed: yearly growth is log-normal (arithmetic mean the real return, arithmetic standard deviation the volatility), the yearly savings are added until the retirement age, then the yearly spending is drawn down for the retirement length. Success is a balance still above zero at the end of the last retirement year; the 10th, 25th, 50th, 75th and 90th percentiles are kept per year. The path count is fixed at 5,000 (never a parameter), each call builds its own random.Random(seed), and the pure-Python loop runs in run_in_threadpool behind a semaphore of two runs: a caller waits at most five seconds, then gets 503 simulation_busy, and the database transaction is ended before the wait so a queued run holds no connection. Balance, spending, savings and return come from resolve_fire_inputs, so the figures match the financial independence report. Stores nothing It returns the same no_history, no_accounts and no_spending statuses, in that order, with no success rate. |
qif_import_service |
qif_import_service.py |
QIF file preview read with the user’s date order and number format; rows commit through the shared bank-file commit |
rule_actions |
rule_actions.py |
Applies a matched categorization rule’s category, subcategory, tag and suggested-label actions to a transaction, shared by rule apply and the importers |
rule_limits |
rule_limits.py |
Save-time caps on a rule’s condition tree (30 nodes, 4 levels) and the regex probe, raised as RuleLimitError |
rule_regex |
rule_regex.py |
The only module that compiles or runs a user-supplied rule pattern: pattern and text length caps, compile guard, 50 ms search timeout (RegexTimeout) and the hostile-sample probe |
reconcile_service |
reconcile_service.py |
Reconcile preview, commit and undo: difference from the latest reconcile, snapshot or opening balance, lock of the window’s cleared transactions, statement-date snapshot |
running_balance |
running_balance.py |
Per-row running balance and snapshot drift for one account, a window SUM() OVER over the cash scope anchored on the latest snapshot |
saved_filter_service |
saved_filter_service.py |
Saved filter list, create, rename and delete, scoped to one user; unique names and the 100-filter cap |
saved_report_service |
saved_report_service.py |
Saved report list, create, update and delete, scoped to one user; unique names and the 100-report cap. An update applies the rename first, so a refused rename leaves the definition untouched |
dashboard_service |
dashboard_service.py |
Dashboard list, first-dashboard creation, create, update, delete and reorder, scoped to one user; a per-user write lock, the 8-dashboard cap, the one-dashboard-per-card check and the hidden-card merge on delete |
savings_goal_service |
savings_goal_service.py |
Savings goal CRUD, linked account progress |
savings_rate_service |
savings_rate_service.py |
Monthly savings rate in the base currency: income and expenses over the flow scope, plus the amount moved to off-budget accounts (saving scope) counted as saved (read-only) |
scheduling_service |
scheduling_service.py |
Shared helpers for lifespan/arq scheduled tasks (e.g. get_users_with_active_accounts) |
session_service |
session_service.py |
Tracked browser sessions: start at cookie sign-in, last_seen_at at refresh rotation, list (with the synthetic legacy entry), revoke one or all others (stamping the legacy cutoff), daily purge; keeps a coarse browser/OS label and a /24 or /48 network, never the raw User-Agent or address |
snapshot_service |
snapshot_service.py |
Snapshot CRUD, counts, batch operations |
split_service |
split_service.py |
The one place that saves or removes the category lines of a transaction: 2 to 20 non-zero lines summing to the parent’s amount, a NULL parent category, no split of a reconciled row or a transfer leg |
spendable_service |
spendable_service.py |
End-of-month spendable figure: liquid balance minus uncovered planned expenses and budget reserves (read-only). Counts on-budget accounts only (the balance forecast and net worth keep off-budget ones), and nets a recorded future-dated transfer pair between pooled accounts out of expected income and expenses |
subcategory_service |
subcategory_service.py |
Subcategory CRUD, reorder |
system_service |
system_service.py |
System info aggregation |
payee_service |
payee_service.py |
Payee CRUD (list with search over names and aliases, usage count and sort, create, rename keeping the old name as an alias, default category, delete unused, merge with a preview of the affected transactions) and name normalization, the one grouping rule shared with migration 120 |
payee_validation |
payee_validation.py |
Payee ownership checks (resolve_owned_payees, resolve_owned_payee): an id of another user is a 404 payee_not_found |
tag_service |
tag_service.py |
Tag CRUD, assignment to transactions |
tag_validation |
tag_validation.py |
Shared tag input validation helpers |
totp_service |
totp_service.py |
TOTP secret generation/verification (pyotp) for 2FA |
transaction_import_core |
transaction_import_core.py |
Shared create path for bank-file imports (external-id de-duplication, a savepoint per row, rule actions, import summary) |
transaction_lock |
transaction_lock.py |
The single guard every transaction writer calls: assert_mutable raises 409 transaction_locked for a reconciled row (its transfer_group_id is a locked field too), partition_locked splits a batch, release_account_locks runs before a permanent account or institution delete |
transaction_trash |
transaction_trash.py |
Transaction trash: list, restore, permanent delete, empty |
transaction_service |
transaction_service.py |
Transaction CRUD, filtering/sorting, export queries, batch operations, base-currency conversion |
transfer_service |
transfer_service.py |
Transfers: create a linked pair, link and unlink existing transactions, find the peer leg, attach transfer_peer to list rows, suggest likely pairs, and find_inconsistent_groups (a group must have two legs, one trash state and two different accounts) |
user_service |
user_service.py |
User upsert from claims (only updates non-None fields), profile update, password change |
backup_import |
backup_import/ |
JSON backup restore, split by section (accounts, catalog, planning, savings, transactions, views) with a shared preview and restore context; import_service delegates to it |
duplicate_service |
duplicate_service.py |
Probable-duplicate detection and dismissals |
envelope_service |
envelope_service.py |
Envelope CRUD, contributions, pay with idempotency key |
loan_payoff_service |
loan_payoff_service.py |
Loan payoff projection from the account’s rate and payment |
metrics_service |
metrics_service.py |
In-process Prometheus counters/histograms, job outcomes (record_job) and the Redis queue gauge behind GET /metrics |
net_worth_projection |
net_worth_projection.py |
Net worth projection |
payee_suggestion_service |
payee_suggestion_service.py |
Category suggestion for a payee (default or history) |
pivot_grid |
pivot_grid.py |
Snapshot/institution grid pivot |
sorting |
sorting.py |
Shared sort helpers |
spreadsheet_parsing |
spreadsheet_parsing.py |
CSV/ODS/XLSX parsing helpers (parse_bank_csv) |
webhook_service |
webhook_service.py |
Webhook CRUD, HMAC-signed delivery, retry sweep |
Lifespan Background Tasks
Section titled “Lifespan Background Tasks”Up to 14 background tasks are scheduled in main.py via asyncio.create_task during the FastAPI lifespan. When REDIS_URL is set, the equivalent work runs as arq cron jobs in the worker container instead (see worker.py’s 11 cron jobs).
| Task | Schedule | Description |
|---|---|---|
daily-backup |
Every 24h | SQLite sqlite3.backup() to ./data/backups/patrimoine_<timestamp>.db; skipped for PostgreSQL; disabled when AUTO_BACKUP=false |
recurring-snapshots |
Every 1h | Checks recurring_snapshots table and reschedules due rules (no snapshot is created and no email is sent) |
planned-transaction-sweep |
Every 1h | Posts the planned transactions that fall due (weekend shift and skipped dates applied) and sends reminders |
fx-refresh |
Every 24h | Auto-fetches exchange rates from frankfurter.app for users with non-EUR accounts |
audit-cleanup |
Every 24h | Deletes audit logs older than audit_log_retention_days (default 90) |
revoked-token-purge |
Every 24h | Deletes expired rows of revoked_tokens |
webhook-delivery-cleanup |
Every 24h | Deletes webhook delivery history older than WEBHOOK_DELIVERY_RETENTION_DAYS (default 30) |
session-purge |
Every 24h | Deletes user_sessions rows whose expires_at is more than 30 days past |
delta-cache-refresh |
Every 1h | Recomputes daily net-worth deltas for heatmap visualization |
alert-check |
Every 30 min | Evaluates each user’s alert rules, net worth thresholds and due snapshot reminders, stores one alert per occurrence and posts alert.fired to the user’s webhooks (the arq cron runs it at :00 and :30) |
price-refresh |
Every 24h | Auto-refreshes current holding prices from Yahoo Finance, deduped globally by ticker |
scheduled-reports |
Every 1h | Sends due scheduled net-worth report emails (the arq cron runs this once daily at 07:00 UTC instead) |
webhook-delivery-retry |
Every 60s | Retries pending webhook deliveries; the only retry mechanism guaranteed present since the arq worker is optional |
The arq worker does not run session-purge, revoked-token-purge or webhook-delivery-retry; they stay in the API process.
All tasks log exceptions via add_done_callback(_log_task_exception) and continue running. The daily backup task is only created when auto_backup is True (opt-in, default False) — see Backup & Restore for details.
Configuration
Section titled “Configuration”Settings are defined in app/config.py as a Pydantic BaseSettings subclass, loaded from environment variables and .env file.
| Setting | Type | Default | Description |
|---|---|---|---|
database_url |
str |
sqlite+aiosqlite:///data/patrimoine.db |
SQLAlchemy async database URL |
debug |
bool |
False |
Enables debug logging and SQL echo |
app_name |
str |
Patrimoine |
Application name (FastAPI title) |
oidc_issuer |
str |
http://localhost:9000/application/o/patrimoine |
OIDC provider issuer URL (trailing slash auto-stripped) |
oidc_client_id |
str |
"" |
OIDC client ID |
oidc_client_secret |
SecretStr |
"" |
OIDC client secret |
oidc_enabled |
bool |
False |
OIDC is active only when this and oidc_client_id are set |
oidc_redirect_uri |
str |
"" |
OIDC redirect URI; derived as FRONTEND_URL + /auth/callback when empty |
frontend_url |
str |
http://localhost:8080 |
Frontend origin (CORS) |
jwt_secret |
SecretStr |
"" |
HS256 signing secret for local JWTs (required, 32+ characters) |
jwt_algorithm |
str |
HS256 |
JWT algorithm for local tokens |
access_token_expire_minutes |
int |
30 |
Access token TTL |
refresh_token_expire_days |
int |
7 |
Refresh token TTL |
registration_enabled |
bool |
False |
Whether new user registration is allowed |
trusted_proxy |
bool |
True |
Trust X-Real-IP header from nginx |
e2e_test_mode |
bool |
False |
Auth bypass for Playwright tests (requires DEBUG=True) |
audit_log_retention_days |
int |
90 |
Days before audit logs are auto-deleted |
auto_backup |
bool |
False |
Enable automatic SQLite backup on startup + every 24h (opt-in) |
redis_url |
str |
"" |
Redis URL for arq worker (empty = asyncio fallback); also enables the worker job counts in GET /metrics |
metrics_token |
SecretStr |
"" |
Bearer token for GET /metrics (32+ characters; empty disables the endpoint) |
max_request_body_bytes |
int |
2 MiB | Request body cap |
max_import_body_bytes |
int |
12 MiB | Request body cap for import endpoints |
db_pool_size / db_max_overflow |
int |
5 / 10 |
Connection pool for PostgreSQL |
trusted_proxies |
str |
"" (private networks) |
Peers allowed to set X-Forwarded-* / X-Real-IP |
max_session_days |
int |
30 |
Lifetime of a tracked session |
login_max_attempts / login_lockout_minutes |
int |
5 / 15 |
Failed-login lockout (login_lockout_max_minutes 240, login_account_max_attempts 25) |
max_tokens_per_user |
int |
10 |
API token cap |
webhook_delivery_retention_days |
int |
30 |
Webhook delivery history retention |
smtp_host, smtp_port (587), smtp_user, smtp_password, smtp_from |
Outgoing email | ||
admin_emails, admin_subs |
str |
"" |
Bootstrap admins |
expose_api_docs, docs_url, site_url, help_url |
API docs exposure and documentation links | ||
mobile_oidc_client_id, mobile_oidc_issuer |
str |
"" |
Mobile app OIDC client |
jwt_accept_legacy_key |
bool |
True |
Accept tokens signed with the raw JWT_SECRET (issued before the signing key was derived from it) |
frankfurter_api_url, yahoo_search_url, yahoo_chart_url |
str |
public endpoints | Market-data sources |
e2e_bypass_token |
SecretStr |
"" |
Test-mode bypass token (generated at startup when empty) |
The list is a selection; the full reference is Environment Variables.
Dependencies
Section titled “Dependencies”Key Python packages (from pyproject.toml):
| Package | Version Constraint | Purpose |
|---|---|---|
| fastapi[standard] | >=0.162.0,<0.163.0 |
Web framework |
| uvicorn[standard] | >=0.54.0,<0.55.0 |
ASGI server |
| sqlalchemy[asyncio] | >=2.1.6,<2.2.0 |
ORM (async) |
| aiosqlite | >=0.22.1,<0.23.0 |
Async SQLite driver |
| alembic | >=1.20.1,<1.21.0 |
Database migrations |
| pydantic | >=2.14.1,<2.15.0 |
Data validation |
| pydantic-settings | >=2.16.1,<2.17.0 |
Settings management |
| PyJWT[crypto] | >=2.15.1,<3.0.0 |
JWT encoding/decoding (with crypto extras for RS256) |
| httpx | >=0.28.1,<0.29.0 |
Async HTTP client (OIDC, FX fetch) |
| odfdo | >=3.27.3,<3.28.0 |
ODS file parsing/export |
| openpyxl | >=3.1.5,<3.2.0 |
XLSX file parsing/export |
| pwdlib[argon2] | >=0.3.1,<0.4.0 |
Argon2 password hashing |
| email-validator | >=2.3.0,<3.0.0 |
Email address validation for Pydantic EmailStr fields |
| pyotp | >=2.10.0,<3.0.0 |
TOTP generation/verification for 2FA |
| cryptography | >=50.0.2,<51.0.0 |
Cryptographic primitives backing JWT/OIDC and password hashing |
| aiosmtplib | >=5.1.3,<6.0.0 |
Async SMTP client for transactional email |
| ofxtools | >=1.1.1,<2.0.0 |
OFX file parsing for bank statement import |
| regex | >=2026.9.29,<2027.0.0 |
Time-limited matching for the regex rule operator (the standard re module cannot interrupt a search) |
| defusedxml | >=0.7.1,<0.8.0 |
Hardened XML parsing for CAMT bank statement import |
Optional dependencies:
| Extra | Package | Purpose |
|---|---|---|
postgres |
asyncpg |
PostgreSQL async driver |
redis |
arq |
Background task queue |
dev |
pytest, pytest-asyncio, pytest-timeout, pytest-rerunfailures, pytest-cov, pytest-xdist, ruff |
Testing and linting |
Related Documents
Section titled “Related Documents”- Database Architecture — Model details, Alembic migrations, SQLite WAL
- Middleware — Request ID, metrics, security headers, rate limiting, body limit, CSRF, audit logging, CORS
- Authentication — Dual auth (OIDC + local JWT), API tokens
- API Design Conventions — Error format, pagination, naming