Architecture¶
Django apps¶
Comaney is split into three Django apps and a project package.
comaney/ (project package)¶
Settings, root URL configuration, and middleware. Also contains a dynamic markdown renderer that serves optional public pages (imprint, privacy policy, etc.) from environment-variable-supplied markdown content.
feusers/¶
Everything related to users and authentication. This app defines the FeUser model, which is Comaney's custom user type. It does not use django.contrib.auth.User.
Responsibilities: registration, login, logout, email confirmation, password reset, multi-method two-factor auth (TOTP and FIDO2/WebAuthn security keys), API key management, account settings, and account deletion.
Session authentication works by storing feuser_id in the Django session after a successful login. The @feuser_required decorator (in budget/decorators.py) checks this on each request and sets request.feuser.
budget/¶
The core budgeting functionality. Contains all models (Expense, ScheduledExpense, Category, Tag, DashboardCard), all budgeting views, and the business logic for:
- Expense CRUD and bulk actions
- Scheduled expense template management and cron-driven generation
- Category and tag management
- The modular dashboard (YAML card parsing and data computation)
- AI express creation, and the shared AI-calling infrastructure other apps' AI features build on (see AI architecture below)
- Email notifications
- End-of-month allowance handling logic
- The query language parser (translates the search bar mini-language into Django Q objects)
api/¶
The REST API (v2) and the source of every MCP tool. Each action is defined once as an Operation in a registry (api/registry.py): name, permission concern (Expenses, Projects, ...), read or write, HTTP method and path, description, JSON-schema parameters and a handler. The handlers live in api/operations/, one module per concern. api/rest.py turns the registry into URL routes (Bearer API key or browser session with CSRF), and the MCP server turns the same registry into tools, so both always share validation, permissions and side effects.
Handlers contain no business logic of their own: they validate input (reusing the web forms where there are any) and call the same service functions the web views call. Two-step destructive operations (bulk delete, Catalog Partnership onboarding) return a signed, caller-bound confirmation token from their preview (api/operations/confirm.py).
After adding or changing an operation, list it in the user manual's API page and regenerate the Postman collection:
mcp_server/¶
The Model Context Protocol server at /mcp and the agent keys behind it. AgentKey stores a label, a visible prefix, the SHA-256 hash of the key and a read/write flag per concern. mcp_server/protocol.py (JSON-RPC, Django-free) answers initialize, tools/* and resources/* statelessly; mcp_server/endpoint.py authenticates the key, lists only the operations it allows and adds two tools that every key has, docs_search and docs_read, over the user manual (mcp_server/docs.py). The manual pages are also MCP resources.
Shared services¶
Every write the web views, the REST API and the MCP server perform goes through one service function, so all three apply the same rules, notifications and side effects:
| Area | Module |
|---|---|
| Expenses (create, edit with approval reset, delete, clone, bulk, overlays) | budget/expense_service.py |
| Recurring expenses | budget/scheduled_service.py |
| Categories and tags (with Catalog Partnership sync) | budget/catalog_service.py |
| Dashboards and cards | budget/dashboard_service.py |
| Account settings | feusers/settings_service.py |
| Projects | buddies/services/project_actions.py |
| Contacts | buddies/services/contact_actions.py |
| Approvals, project expense removal | buddies/services/approvals.py |
| Settlements | buddies/services/settlement_actions.py |
| Catalog Partnership | buddies/services/partnership_actions.py |
URL routing¶
/ → feusers app (login, register, public pages)
/budget/ → budget app (expenses, dashboard, scheduled, categories)
/api/v2/ → api app (Bearer token REST API)
/mcp → mcp_server app (MCP server, agent keys)
/admin/ → Django admin
/docs/ → Docs site served from docs/build/site/
Middleware¶
Requests pass through the middleware stack in this order:
- SystemMisconfiguredMiddleware: injects a warning banner if the app is misconfigured (e.g., SMTP not set up).
- SecurityMiddleware: Django's standard security headers.
- WhiteNoiseMiddleware: serves compressed static files directly from Gunicorn.
- SessionMiddleware: cookie-based session support.
- LastSeenMiddleware: updates
feuser.last_seenat most once every 5 minutes for authenticated requests. - Standard Django middleware (CSRF, messages, X-Frame-Options, etc.).
Financial periods¶
All budget data is scoped to a financial period. The period is determined by the user's month_start_day and month_start_prev settings. budget/date_utils.py provides financial_month_range() and financial_year_range() to compute the date boundaries for any given month or year.
The dashboard, expense list, API and MCP tools all accept ?year= and ?month= parameters and use these helpers to scope queries.
Dashboard card system¶
Cards are stored as DashboardCard model instances containing only a raw YAML string and a creation timestamp. All layout and configuration information lives inside the YAML.
When the dashboard loads, the frontend fetches all cards via the session-authenticated card API. For each card, the server parses the YAML, applies the card's query filter against the current period, and computes either a scalar value (cell cards) or grouped chart data (bar/pie charts). The result is returned as JSON.
AI architecture¶
Four features call the AI: express expense creation (budget/express_service.py, view in budget/views/express.py), dashboard card AI assist (budget/dashboard_card_ai.py), Catalog Partnership tag/category mapping (buddies/services/partnership_ai.py), and Unclassified Expenses AI-solve (budget/unclassified_ai.py).
All of them talk to the AI exclusively through budget/ai_service.py's AIService class -- nowhere else in the codebase talks to an AI service or hand-parses an AI JSON response. The code is split in two layers:
AIService(provider-neutral) owns how to talk to the AI safely: own-credentials/shared-access resolution, JSON-response recovery (one repair retry if the model's reply doesn't parse), the{"result": "good"/"fail"}envelope, trial-budget billing, and the side effects of a failing shared access: disabling the shared access and emailing the admin (see Shared AI Access) on an authentication or billing failure, for every AI feature alike.- The active AI provider (
ai_providers/, picked by theAI_PROVIDERenvironment variable) owns everything vendor-specific: the SDK or HTTP call, model, limits, prices and the cost of each request, and mapping vendor errors to the typed errors inai_providers/errors.py(AIAuthenticationError,AIBillingError,AITransientError,AIRefusalError,AIInvalidResponseError,AIBudgetExceededError,AIVisionUnsupportedError). Each provider is one file with oneAIProvidersubclass (ai_providers/base.py);ai_providers/claude.pyis the only file that importsanthropic;ai_providers/openai_compat.py(OpenAICompatibleProvider) is the shared base for services speaking the OpenAI Chat Completions API (deepseek.py,local.py, community providers), with its HTTP transport (httpx2) injectable for tests. How to write one: Writing an AI provider. The package is Django-free:comaney/settings.pyloads and validates the provider once at startup (ai_providers.resolve_ai_provider), and any configuration error takes the site down with the misconfigured page.
AIService builds an AIRequest (feature name, system prompt as a list of SystemParts that the provider may cache, user text, optional images, the feature's max_tokens) and passes it to get_provider().send(), together with the user's own settings, or {} for shared access. The provider returns an AIResponse (text, cost in cents, token usage).
AIService deliberately knows nothing about categories, tags, projects, or dashboard cards. Each feature module owns what to ask: assembling its own system prompt text and validating the payload it gets back before it's used. A new AI feature is one more prompt_* method on AIService plus a feature module that builds its prompt and calls it -- it never needs to reimplement JSON recovery, billing, or error handling.
Static files¶
SCSS and JS source files in build/ are compiled to static/dist/ by the build script. WhiteNoise serves them with a content-hash in the URL (via CompressedManifestStaticFilesStorage), enabling aggressive browser caching.
Authentication flows¶
Session auth (web UI): On login, the server looks up the FeUser by email, verifies the submitted password with check_password(), and only then stores the user's database PK in the session as feuser_id. If the account has any second factor (FeUser.has_2fa_enabled), a twofa_pending_id is stored instead and the user is redirected to twofa_verify; the full session (feuser_id) is only established after a factor is validated.
Second-factor architecture (feusers/second_factor_registry.py, feusers/second_factor_service.py): TOTP and FIDO2/WebAuthn are both specializations of an abstract SecondFactorAuth model (feusers/models/second_factor.py); concrete subclasses are TOTPFactor and WebAuthnFactor. Each method registers itself once via register_factor_type(FactorType(...)), which records its model, display name, setup URL, and login-challenge template. feusers/views/twofa.py (login verification, method switching, removal, primary selection, recovery-code regeneration) and feusers/views/account.py (the profile list) drive entirely off this registry via get_all_factors()/method_key_of() and never branch on a method name directly, so adding a further method needs only a new model, a register_factor_type() call, a setup view, and one entry in twofa.py's _LOGIN_VERIFIERS dispatch table.
Exactly one factor (of any type) is is_primary=True per user at a time, enforced by second_factor_service.set_primary(). A single global FeUser.twofa_recovery_hash (not per-method) protects the whole account: it is generated once on a user's first-ever factor, and consuming it via second_factor_service.consume_recovery_code() deletes every factor of every method, since a user who needed the recovery code has just proven they can reach none of them.
Bearer token auth (REST API): The Authorization: Bearer <key> header is read on every API request. The key is looked up against FeUser.api_key in the database, and the operation runs only if FeUser.api_key_permissions allows its concern and access (403 otherwise). Agent keys use the same permission model (api/permissions.py::KeyAccess); browser session callers are not limited.
Agent key auth (MCP): /mcp accepts only agent keys, never the API key, and the REST API never accepts agent keys. The key is hashed and looked up in AgentKey; demo users have none. A browser Origin header must name the site itself.
Registration protection: The registration form uses a client-side proof-of-work challenge to deter bot signups. The browser must compute a hash nonce before the form can be submitted.