Skip to content

Contributing

This page is the shared source of engineering rules and workflow conventions for anyone proposing a change to this repository, human or automated. If a rule here conflicts with something older written elsewhere, this page wins.

The backend (backend/app/) is a production FastAPI service, already built out — not a stub. Write and review code like a senior backend engineer maintaining an existing system: match what’s already there over textbook-ideal patterns.

  • Layering: routers/ (I/O only) → services/ (business logic) → models/ (SQLAlchemy Mapped/ORM), with schemas/ (Pydantic) kept separate. Never return an ORM object from a router unless the route declares a response_model (with from_attributes) that filters it; otherwise build the matching schemas/*.py model (the XCreate/XUpdate/XOut pattern already used in, e.g., app/schemas/account.py).
  • Async all the way down: the engine is AsyncSession/create_async_engine (SQLAlchemy 2.0, async). No sync DB or HTTP calls in request paths — if a blocking call is genuinely unavoidable, wrap it in run_in_threadpool and say so explicitly in a comment, rather than letting it block the event loop silently.
  • Errors go through app/errors.py: raise APIError, not a bare HTTPException, so responses stay in the standardized {"error": {"code","message","detail"}} shape.
  • Auth via existing dependencies: use app/dependencies.py plus app/utils/oidc_client.py / local_jwt.py (dual OIDC + local JWT, selected by the token’s iss claim). Don’t hand-roll token parsing in a route.
  • Test the way conftest.py already does: reuse the test_db (in-memory SQLite override) and async client fixtures in backend/tests/conftest.py. asyncio_mode is auto, so async tests don’t need @pytest.mark.asyncio.
  • Ruff-clean, no mypy: this repo enforces Ruff (E,F,I,UP,B,SIM,C901) via pre-commit. There is no type checker configured.
  • Migrations via Alembic: schema changes must work against both SQLite (default, sqlite+aiosqlite) and PostgreSQL (optional, asyncpg) — see app/database.py’s IS_SQLITE branch. Don’t write a migration or query that only works on one.
  • Secrets/config: stay in app/config.py (Pydantic Settings). Never hardcode DB URLs or JWT secrets in route code.
  • New dependencies: check backend/pyproject.toml before assuming a library is available, and discuss before adding one.
  • Existing patterns win: match what’s already in the repo over what a textbook would suggest. If existing code violates a rule above, don’t unilaterally “fix” it as a side effect of an unrelated task — flag it and ask, or fix it in its own commit.
  • Pagination: apply the standard shape to any list endpoint that lacks it: page: Pagination = Depends(pagination_params) from app/dependencies.py (see app/routers/audit_logs.py). Only the endpoints in the exceptions table of API Design Conventions keep their own Query(...) declarations.

Three hard rules, no exceptions:

  • No bare except: / swallowed exceptions.
  • No unbounded list endpoints without pagination.
  • No skipping input validation “just for this one internal endpoint” — Pydantic schemas are the contract, not optional.

See API Design Conventions, Code Style, Database Architecture, Testing, and Backend Development for the full detail behind these rules.

Dropdown selectors (@/components/ui/select) must always display the human-readable, correctly-localized value — never the raw key/id, and never a label rendered in a stale locale. This has recurred more than once, in two distinct ways:

  1. <SelectValue> with no explicit children render function falls back to base-ui’s internal item registry, and when that registry hasn’t registered the selected item yet (e.g. the option list is populated asynchronously from a query, or the selected value was set before the accounts/institutions/categories list finished loading), it silently renders the raw value string (the id) instead of the label.
  2. Even for a fully static/enum option set, the registry snapshot can be captured from whichever locale was active the last time that item rendered/registered, so a language switch can leave the closed trigger showing yesterday’s-locale text until the item re-registers (e.g. the dropdown is reopened) — a real, user-visible bug, not just an async-data edge case.

Any Select whose displayed value is derived from translated content (FormattedMessage, intl.formatMessage, or a lookup into dynamic/id-keyed data such as accounts, institutions, categories) must pass an explicit function child to SelectValue that computes the current label itself, live, rather than relying on the registry:

// Dynamic/id-keyed data:
<SelectValue placeholder="...">
{(val: string | null) => {
if (!val) return null;
const account = accounts?.find((a) => String(a.id) === val);
return account?.name ?? val; // val is a last-resort fallback only, never the primary path
}}
</SelectValue>
// Static/enum options, still translated:
<SelectValue>
{(val: string | null) => {
const labelId = val === 'pending' ? 'bills.status.pending' : /* ... */ 'common.all';
return intl.formatMessage({ id: labelId });
}}
</SelectValue>

See AccountSelect.tsx, InstitutionSelect.tsx, and ImportOfxDialog.tsx’s account picker for the dynamic-data pattern, and PlannedPage.tsx’s type/frequency selects for the static/enum pattern.

Note: A bare <SelectValue /> is only safe when the displayed value is never translated at all (e.g. a raw literal, a number, a non-localized code) — if the label comes from intl/FormattedMessage in any way, static or dynamic, it needs the explicit function child.

See Frontend Patterns for more conventions.

  • CI: .forgejo/workflows/ci-android.yml is path-filtered to android/** and backend/openapi.json (the DTO contract test compares the app against that schema) and runs lint/unit tests/debug-APK build on every push/PR to main — same path-filtered pattern ci-backend.yml/ci-frontend.yml/ci-web.yml use for their own scopes (see CI/CD Pipeline).
  • Versioning is independent of the app-wide v* tag.
    • Backend and frontend deploy together and share that tag; Android ships to app stores/sideload on its own schedule gated by different things (review, signing) entirely unrelated to a backend/frontend deploy.
    • When Android actually cuts a release, run android/scripts/release.sh X.Y.Z: it bumps android/app/build.gradle.kts’s versionName/versionCode, commits, and creates the android-vX.Y.Z tag. The release workflow refuses a tag that does not match the file. This is already invisible to the changelog pipeline, so it needs no changes there.
  • No automated changelog for Android — write Play Store release notes by hand when shipping (user-facing prose, not a commit dump).
  • Screen headers go through ui/common/PatrimoineTopAppBar.kt, never a bare TopAppBar call. Per-row edit/archive/delete actions go through ui/common/RowActionsMenu.kt (a ⋮ icon opening a List<RowMenuAction>), not a bespoke menu per screen. See “Shared UI chrome” in the Android App Specification (docs/specs/android-app.md in the repository) for the full pattern and why PatrimoineTopAppBar is built on Surface/Row rather than M3’s TopAppBar composable.
  • Small, reviewable commits — don’t bundle an unrelated refactor into a feature commit.
  • If you’re fixing a bug, write the regression test first (or alongside), so it’s clear what was actually broken and that it’s actually fixed.
  • Before committing any change to backend/app/, keep backend/openapi.json up to date: run cd backend && uv run python scripts/export_openapi.py and include the diff in the same commit.
  • Any commit that adds, removes, or changes an endpoint in backend/app/routers/*.py must regenerate backend/openapi.json in the same commit — the Developer Manual’s API Reference page is generated from that file when the docs site builds, so there is no hand-maintained endpoint list to update.
    • API Design Conventions only needs touching if the change introduces or breaks a general design pattern, not for routine endpoint additions.
  • The same router-touching commit also bumps backend/pyproject.toml’s version field, using the same feat=minor/fix=patch/BREAKING=major mapping described under Commits below, scoped to the API surface only (a router change bumps it; a service-layer refactor or test-only change does not).
  • Any Alembic migration that requires a manual admin action beyond the automatic alembic upgrade head (a new required .env var, a data backfill/cleanup step, a breaking removal of user-facing data, or anything tagged BREAKING CHANGE/!) must document that step in Updating in the same commit. Routine, fully-automatic/additive migrations are exempt.
  • Any change a user would notice — a new feature, a changed workflow, a renamed/moved setting, a new limitation — must update the matching User Manual page under web/documentation/src/content/docs/en/docs/user-manual/ in the same commit, together with its fr/es/de/it translations (npm run verify:locale-parity enforces this in CI).
    • Purely internal changes (refactors, backend-only fixes with no user-visible behavior change, dependency bumps) don’t need a manual update.
  • Every page under web/documentation/src/content/docs/en/ also exists, translated, at the same path under fr/, es/, de/ and it/ (only the generated API Reference stays English-only), so a new English page ships with its four translations in the same commit.
    • Each translation stores the hash of the English body it was made from in its sourceHash frontmatter field: changing the English body makes npm run verify:locale-parity and the Docs CI job fail until the translations are updated.
    • For a non-semantic English edit only (a typo, a link target, formatting), run npm run i18n:ack -- <path> in web/documentation to re-stamp the hash instead (it covers the Italian copies too); the re-stamp shows in the diff for review.
    • Terminology and translation conventions live in web/documentation/i18n/glossary.md.
  • Update every other doc that references what changed (endpoint behavior, config, values, examples) in the same commit — no commit ships with stale docs.
  1. Run the tests. If none exist for the code you touched, write them (happy path plus one failure case minimum) before calling the change done — reuse the test_db and async client fixtures already set up in backend/tests/conftest.py.
  2. If you touched anything under backend/app/ (routers, schemas, or anything that shapes a response), run cd backend && uv run python scripts/export_openapi.py and check the diff before committing.
    • Always invoke it via uv run, never a bare .venv/bin/python or an ad hoc interpreter — a stale/cached editable install will silently report the old version and produce a false “no diff”.
  3. If the commit changes backend/pyproject.toml’s version field, for any reason, re-export openapi.json as above and also run cd backend && uv lock so the lock file’s own package entry matches — verify both with uv lock --check before committing.
    • Skipping either breaks a different CI job later:
      • a stale openapi.json fails the backend test job’s --check step.
      • a stale uv.lock fails the Docker image build’s uv sync --locked step, which only shows up at tag-build time.
  4. Run Ruff. Fix what it flags, don’t just report it.
  5. Actually exercise the endpoint where feasible (test client, curl) rather than trusting that it compiles.
  6. If a migration is involved, confirm it applies cleanly against SQLite and downgrades cleanly.
  7. If the change is user-visible, confirm the matching User Manual page in web/documentation was updated in the same commit, with its fr/es/de/it translations updated and npm run verify:locale-parity passing.

Use Conventional Commits format. The changelog and release notes are generated from commit history by git-cliff.

Type When to use Version bump
feat New feature or capability Minor (e.g., 0.46.0 → 0.47.0)
fix Bug fix Patch (e.g., 0.46.0 → 0.46.1)
refactor Code restructuring without behavior change None
docs Documentation-only changes None
chore Maintenance, dependencies, tooling None
BREAKING CHANGE: or ! Incompatible API change Major
  • Scope names a real feature/module, e.g. feat(tui):, fix(cmd):.
  • Keep the subject line under 72 characters, imperative mood (“add”, “fix”, “remove” — not “added”, “fixes”, “removed”).
  • Reference the affected code area in the body when applicable.
  • All commits must be GPG-signed; never bypass signing.
  • CHANGELOG.md is generated by git-cliff from commit history — it isn’t hand-edited.
  • Release tags and release commits are cut by the maintainer as part of the release process, not by a contributor’s feature commit.
  • Never commit secrets, API keys, or credentials.

See Git Commit Conventions for the full type/scope reference and Releases for how tags and the changelog are produced.

Pages live under web/documentation/src/content/docs/en/docs/<manual>/, each with a title / description / sidebar.order frontmatter block and root-relative links (e.g. /en/docs/developer-manual/contributing/). The landing page is src/content/docs/{locale}/index.mdx in the same project, using template: splash with no sidebar. Preview locally with npm --prefix web/documentation run dev. Before pushing, run this from inside web/documentation:

Terminal window
npm test && npm run verify:locale-parity && npm run check && npm run build && npm run verify:build && npm run verify:no-leakage

The API Reference page is generated from the backend’s OpenAPI schema at build time — there’s no hand-maintained endpoint list to keep in sync. Every page exists in English, French, Spanish, German and Italian, and the parity check fails on a missing or stale translation.

Diagrams are written in Mermaid inside .mdx pages with the Mermaid component: add import Mermaid from '../../../../../components/Mermaid.astro';, then <Mermaid code={...} /> with the source as a template literal. The source starts with its diagram-type line, followed by single-line accTitle: and accDescr: lines, which the build requires; the description is also the fallback shown without JavaScript. An optional minHeight reserves space so the page does not shift while the diagram loads. verify:build checks that Mermaid ships only as same-origin JavaScript and that every diagram keeps its fallbacks.

Patrimoine is released under the MIT License. The full text is in the LICENSE file at the root of the repository.