Architecture — Modular Flask core and capabilities

ContaAutónomo is a Flask application split into a small core and a fleet of self-contained modules. The core wires up the database, request routing and a handful of shared services; the ModuleManager discovers, loads and lifecycles modules at startup; modules register their own SQLAlchemy models, Blueprints and capabilities. This page walks through that architecture top-down: the high-level diagram, the CoreServices API, the module lifecycle, the capability system, a minimal example module, the pluggable auth provider system, and the on-disk project layout.

High-level architecture

The Flask core (app.py) builds the application factory and exposes a CoreServices bag to every module. The ModuleManager discovers modules in modules/, instantiates them with a reference to the core, and lets each one register its own models and routes. Modules talk to each other only through registered Blueprints and through the capability registry — never by direct import.

flowchart TB
    subgraph Core["Core (app.py)"]
        AppFactory[Flask app + SQLAlchemy db]
        AuthRoutes[auth_routes.py + auth.py]
    end
    subgraph CS["CoreServices"]
        DB[(db)]
        Storage[FileStorageBackend]
        Logger[ActivityLogger]
        Scheduler[TaskScheduler]
        InvoiceSvc[InvoiceService]
        CurrencySvc[CurrencyService]
    end
    MM[ModuleManager]
    subgraph Mods["modules/*"]
        M1[expenses]
        M2[tax_management]
        M3[documents]
        M4[backup]
        M5[reports]
    end
    Core --> CS
    Core --> MM
    MM -- "instantiates" --> Mods
    Mods -. "use" .-> CS
    Mods -. "register Blueprints" .-> AppFactory
Text version
Core (app.py)
  ├── CoreServices: db, storage, logger, scheduler, invoice_service, currency_service
  └── ModuleManager
         └── modules/* (expenses, tax_management, documents, backup, reports, ...)
               └── use CoreServices, register Blueprints with Flask app

Core services

CoreServices is the contract between the core and the modules. Every module receives a reference to it during construction and uses it for database access, file storage, audit logging, scheduling, invoice operations and currency conversion. The signatures below mirror module_manager.py; modules should treat them as the stable interface.

db

SQLAlchemy session and engine for module models and queries

Methods of db
Signature Description
session.commit() -> None Commit the current SQLAlchemy transaction (writes pending model changes)
session.query(Model) Build a query against a registered SQLAlchemy model class

storage

FileStorageBackend (default LocalStorageBackend); pluggable via set_storage_backend()

</tbody> </table>

activity_logger

FileActivityLogger | DbActivityLogger; pluggable via set_activity_logger()

Methods of storage
Signature Description
save(file_data, relative_path) -> str # storage_key Persist file_data (bytes or file-like object) at and return the storage key used to retrieve it later</td> </tr>
delete(storage_key) -> None Delete the file identified by storage_key from the active backend
send(storage_key, download_name=None) Return a Flask response that streams the file as a download; download_name is the suggested filename
exists(storage_key) -> bool Return True if a file with the given storage_key is present in the active backend
Methods of activity_logger
Signature Description
log(action, category='system', details=None, user=None) -> None Append an audit entry with a UTC timestamp; details may be a string or a JSON-serialisable dict
get_entries(limit=100, category=None, offset=0) -> list[dict] Return up to entries (newest first), optionally filtered by category and paginated by offset</td> </tr> </tbody> </table>

scheduler

TaskScheduler — single in-process daemon thread running registered periodic jobs

</tbody> </table>

invoice_service

InvoiceService — safe invoice CRUD with PAID-locking and PDF attachment helpers

Methods of scheduler
Signature Description
add_job(job_id, func, job_type='interval', interval=3600, time_str='03:00', description='') -> None Register a periodic job; job_type is 'interval' (every seconds) or 'daily' (at HH:MM local time)</td> </tr>
get_jobs() -> list[dict] Return a snapshot of registered jobs including id, type, schedule, last_run, next_run, running flag and last_error
Methods of invoice_service
Signature Description
get(invoice_id) -> Invoice | None Fetch an Invoice by primary key, or return None if it does not exist
attach_pdf(invoice_or_id, file_data, original_filename=None) -> str # storage_key Store the PDF via core.storage, update pdf_hash and pdf_storage_key, and log the action; raises ValueError if the invoice is PAID with a sealed PDF

currency_service

CurrencyService — exchange-rate API with default ECB provider and pluggable custom providers

Methods of currency_service
Signature Description
get_rate(from_currency, to_currency, date_str) -> (rate, actual_date) Return the rate where 1 from_currency = rate to_currency on date_str (YYYY-MM-DD); tries the active custom provider first, then falls back to ECB
convert(amount, from_currency, to_currency, date_str) -> (converted_amount, rate, actual_date) Convert amount across currencies using get_rate; returns the converted amount, the rate applied and the date the rate was sourced from
## Module lifecycle `ModuleManager` runs each module through five phases on application startup. Each phase has a clearly bounded job, which keeps modules independent and makes failures easy to localise. 1. **discover** — scan the `modules/` directory for subdirectories that contain an `index.py`. The directory name becomes the candidate module id; modules disabled in settings are skipped before any code is imported. 2. **load** — import `modules..index`, locate the `BaseModule` subclass and instantiate it with a reference to the core. An exception in any module's `__init__` is logged and isolated; the rest of the application continues to start. 3. **register_models** — call `module.register_models(db)`. The module declares its own SQLAlchemy model classes against the shared `db` instance and returns them in a dict so the manager can expose them to other modules and migrations. 4. **register_routes** — call `module.register_routes(app)`. The module creates one or more Flask `Blueprint` objects (typically with a unique `url_prefix`) and registers them on the application. This is the only place modules touch URL routing. 5. **on_enable** — call `module.on_enable()`, which runs once after every module has registered routes. It is the right place for cross-module setup: registering periodic jobs with `scheduler.add_job`, adding navigation entries, or warming caches that depend on data owned by other modules. ## Capabilities Modules expose typed extension points through capabilities. A producer returns a list of capability dicts from `get_capabilities()`; a consumer asks the `ModuleManager` for capabilities of a given `type` (and optionally `method`) and invokes them. This keeps integrations symmetric — neither side imports the other — and means new providers can be added by enabling a module, without changing existing code. A capability is a plain dict with at least `type`, `method`, `name` and `action` keys, plus any provider-specific metadata. The example below shows a `pdf_signature` module exposing a visual signature capability and a consumer (e.g. an invoice route) finding and calling it: ```python # Producer (in modules/pdf_signature/index.py) def get_capabilities(self): return [{ 'type': 'pdf_sign', 'method': 'visual', 'name': 'Visual Signature', 'accepts': ['pdf'], 'action': self._sign_visual, }] # Consumer (any other module via core.module_manager) signers = core.module_manager.find_capabilities('pdf_sign', method='visual') for s in signers: result = s['action']({'invoice_id': 42}) ``` The same pattern is used for storage backends, activity logger backends, country-specific tax packs and PDF verifiers. `find_capabilities` returns an empty list when nothing is registered, so consumers handle "no provider available" as a normal case rather than an error. ## Building a minimal module The shortest possible module is a class that subclasses `BaseModule`, declares an id and name, registers one model and registers one route. The example below lives at `docs/_includes/code/minimal_module.py` and is included verbatim so the snippet stays in sync with what is actually shipped: ```python from module_manager import BaseModule from flask import Blueprint, render_template class HelloModule(BaseModule): @property def module_id(self): return "hello" @property def name(self): return "Hello" def register_models(self, db): class HelloNote(db.Model): __tablename__ = "hello_note" id = db.Column(db.Integer, primary_key=True) text = db.Column(db.String(200), nullable=False) self.HelloNote = HelloNote return {"HelloNote": HelloNote} def register_routes(self, app): bp = Blueprint("hello", __name__, url_prefix="/hello") @bp.route("/") def index(): notes = self.HelloNote.query.all() return render_template("hello/index.html", notes=notes) app.register_blueprint(bp) ``` Drop this file into `modules/hello/index.py`, add an empty `modules/hello/__init__.py` and a `templates/hello/index.html`, and the next time the app starts `ModuleManager` will discover it, register the `HelloNote` table, and mount `/hello/` automatically. ## Authentication providers Authentication in ContaAutónomo is pluggable. The core ships with `auth_routes.py`, which owns the `/auth/login/` and `/auth/callback/` URLs, but the actual identity verification is delegated to providers contributed by modules. A module advertises its providers by overriding `get_auth_providers()` on its `BaseModule` subclass and returning a list of provider objects (each implementing `id`, `display_name`, `start_login(request)` and `handle_callback(request)`). Two provider families are supported out of the box: - **OAuth providers** (Google, GitHub) — the module redirects the user to the provider's authorize URL during `start_login`, then exchanges the returned code for tokens during `handle_callback` and maps the verified email or `sub` claim to a local user. - **SAML providers** — the module emits a SAML AuthnRequest in `start_login`, validates the signed assertion in `handle_callback` and creates or links the local user from the assertion attributes. `auth_routes.py` keeps no provider-specific state itself: it asks `module_manager.find_capabilities('auth_provider')` for the registered providers, dispatches by `provider.id`, and renders the login page from whatever the modules contributed. Adding SSO for a new identity source is therefore a matter of writing a new module — the core does not change. ## Project structure The repository follows a flat, predictable layout. Core files live at the root, modules live under `modules//`, and each module owns its templates and static assets. A trimmed two-level tree (with one sample module fully expanded) looks like this:
contaautonomo/
├── app.py
├── module_manager.py
├── auth_routes.py
├── auth.py
├── modules/
│   └── expenses/
│       ├── __init__.py
│       ├── index.py
│       └── templates/
│           └── expenses/
│               └── list.html
├── templates/
│   ├── base.html
│   └── index.html
├── static/
├── instance/
│   └── data.db
└── docker-compose.yml
The `instance/` directory holds the SQLite database and any uploaded files in the default `LocalStorageBackend`; it is gitignored. `docker-compose.yml` at the root brings the whole stack up with a single `docker compose up -d` for self-hosted deployments.