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.
Engineering principles
Section titled “Engineering principles”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/(SQLAlchemyMapped/ORM), withschemas/(Pydantic) kept separate. Never return an ORM object from a router unless the route declares aresponse_model(withfrom_attributes) that filters it; otherwise build the matchingschemas/*.pymodel (theXCreate/XUpdate/XOutpattern 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 inrun_in_threadpooland say so explicitly in a comment, rather than letting it block the event loop silently. - Errors go through
app/errors.py: raiseAPIError, not a bareHTTPException, so responses stay in the standardized{"error": {"code","message","detail"}}shape. - Auth via existing dependencies: use
app/dependencies.pyplusapp/utils/oidc_client.py/local_jwt.py(dual OIDC + local JWT, selected by the token’sissclaim). Don’t hand-roll token parsing in a route. - Test the way
conftest.pyalready does: reuse thetest_db(in-memory SQLite override) and asyncclientfixtures inbackend/tests/conftest.py.asyncio_modeisauto, 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) — seeapp/database.py’sIS_SQLITEbranch. 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.tomlbefore 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)fromapp/dependencies.py(seeapp/routers/audit_logs.py). Only the endpoints in the exceptions table of API Design Conventions keep their ownQuery(...)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.
Frontend conventions
Section titled “Frontend conventions”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:
<SelectValue>with no explicitchildrenrender 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 rawvaluestring (the id) instead of the label.- 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 fromintl/FormattedMessagein any way, static or dynamic, it needs the explicit function child.
See Frontend Patterns for more conventions.
Android conventions
Section titled “Android conventions”- CI:
.forgejo/workflows/ci-android.ymlis path-filtered toandroid/**andbackend/openapi.json(the DTO contract test compares the app against that schema) and runs lint/unit tests/debug-APK build on every push/PR tomain— same path-filtered patternci-backend.yml/ci-frontend.yml/ci-web.ymluse 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 bumpsandroid/app/build.gradle.kts’sversionName/versionCode, commits, and creates theandroid-vX.Y.Ztag. 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 bareTopAppBarcall. Per-row edit/archive/delete actions go throughui/common/RowActionsMenu.kt(a⋮icon opening aList<RowMenuAction>), not a bespoke menu per screen. See “Shared UI chrome” in the Android App Specification (docs/specs/android-app.mdin the repository) for the full pattern and whyPatrimoineTopAppBaris built onSurface/Rowrather than M3’sTopAppBarcomposable.
Workflow
Section titled “Workflow”- 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/, keepbackend/openapi.jsonup to date: runcd backend && uv run python scripts/export_openapi.pyand include the diff in the same commit. - Any commit that adds, removes, or changes an endpoint in
backend/app/routers/*.pymust regeneratebackend/openapi.jsonin 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’sversionfield, using the samefeat=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.envvar, a data backfill/cleanup step, a breaking removal of user-facing data, or anything taggedBREAKING 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-parityenforces 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 underfr/,es/,de/andit/(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
sourceHashfrontmatter field: changing the English body makesnpm run verify:locale-parityand 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>inweb/documentationto 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.
- Each translation stores the hash of the English body it was made from in its
- Update every other doc that references what changed (endpoint behavior, config, values, examples) in the same commit — no commit ships with stale docs.
Before you open a pull request
Section titled “Before you open a pull request”- 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_dband asyncclientfixtures already set up inbackend/tests/conftest.py. - If you touched anything under
backend/app/(routers, schemas, or anything that shapes a response), runcd backend && uv run python scripts/export_openapi.pyand check the diff before committing.- Always invoke it via
uv run, never a bare.venv/bin/pythonor an ad hoc interpreter — a stale/cached editable install will silently report the old version and produce a false “no diff”.
- Always invoke it via
- If the commit changes
backend/pyproject.toml’sversionfield, for any reason, re-exportopenapi.jsonas above and also runcd backend && uv lockso the lock file’s own package entry matches — verify both withuv lock --checkbefore committing.- Skipping either breaks a different CI job later:
- a stale
openapi.jsonfails the backend test job’s--checkstep. - a stale
uv.lockfails the Docker image build’suv sync --lockedstep, which only shows up at tag-build time.
- a stale
- Skipping either breaks a different CI job later:
- Run Ruff. Fix what it flags, don’t just report it.
- Actually exercise the endpoint where feasible (test client, curl) rather than trusting that it compiles.
- If a migration is involved, confirm it applies cleanly against SQLite and downgrades cleanly.
- If the change is user-visible, confirm the matching User Manual page in
web/documentationwas updated in the same commit, with its fr/es/de/it translations updated andnpm run verify:locale-paritypassing.
Commits
Section titled “Commits”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.mdis generated bygit-clifffrom 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.
Working on the documentation
Section titled “Working on the documentation”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:
npm test && npm run verify:locale-parity && npm run check && npm run build && npm run verify:build && npm run verify:no-leakageThe 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.
License
Section titled “License”Patrimoine is released under the MIT License. The full
text is in the LICENSE file at the root of the repository.