Prima Get an API key

Module kit / Bookkeeping

Chart of accounts and double-entry posting engine

bookkeeping/chart-of-accounts-and-double-entry-engine

Kind backend
Domain bookkeeping
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, alembic-1.16
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout
Frontends bookkeeping/ui/bookkeeping-financial-dashboard
Reference implementation
  • alchemy_labs/backend/services/accounting/chart_of_accounts.py
  • alchemy_labs/backend/services/accounting/engine.py
  • alchemy_labs/backend/api/routers/accounting_router.py
  • alchemy_labs/backend/services/accounting/journal_service.py
  • alchemy_labs/backend/models/accounting_models.py
  • alchemy_labs/backend/schemas/accounting_schemas.py
  • alchemy_labs/backend/migrations/versions/20260214_add_accounting_tables.py
  • alchemy_labs/backend/tests/accounting/test_engine.py
  • alchemy_labs/backend/tests/v2/coverage/test_voila_g11_endpoint_layer_journal_entries_accounts_fiscal_years_auth_tenant_scoping_lifecycle_via_testclient_.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

Seeds and CRUD-manages a Swiss KMU chart of accounts (Kontenplan) scoped to a workspace, and manages the fiscal-year envelope (Geschäftsjahr) that every journal posting must fall inside, then exposes the one check every other accounting recipe leans on before it touches the ledger: DoubleEntryEngine.validate_lines rejects any journal posting whose debit total and credit total are not exactly equal (services/accounting/engine.py 33 to 61). The invariant that makes the books trustworthy is that this balance check, plus validate_accounts (every referenced account exists, is not a header, is active) and validate_fiscal_year (the fiscal year exists, is open, and the entry date falls inside it) all run before a single JournalEntryLine row is added to the session (journal_service.create_entry, journal_service.py 37 to 40, a neighboring recipe's call site, cited here because it is the only place this recipe's invariant is actually exercised); an unbalanced or structurally invalid entry can never reach the database, not even as a draft.

When to use

Read it before touching seed_default_chart, get_account_tree/get_account, create_account/update_account/delete_account (chart_of_accounts.py), or DoubleEntryEngine.validate_lines/validate_accounts/validate_fiscal_year (engine.py); before adding a route under /api/accounting/accounts or /api/accounting/fiscal-years; and when someone asks "why can't I post this entry", "what's in the default Swiss chart", "why is this account not postable", "what happens when a fiscal year closes", or "does deleting an account block old postings against it". Trigger phrases: "Kontenplan", "Kontenrahmen KMU", "chart of accounts", "fiscal year", "Geschäftsjahr", "validate_lines", "debit/credit balance", "header account", "double-entry", "OR Art. 957-963".

Not this recipe: journal-entry CRUD and the post/void lifecycle itself (POST/PATCH/DELETE /api/accounting/journal-entries, POST .../post, POST .../void, all of journal_service.py's entry-lifecycle methods, and engine.post_entry/void_entry/assign_entry_number/get_account_balance) is a neighboring recipe on the same router and the same engine.py file; this recipe covers only the three validators that gate entry into the ledger (validate_lines, validate_accounts, validate_fiscal_year), not what happens once an entry is posted or voided, though post_entry's own bare-ValueError behavior is cited in Gotchas because it is the same unguarded-exception pattern as this recipe's own validators. Also not this recipe: invoices, expenses, VAT reports, bank reconciliation, Gmail/Drive/Sheets receipt ingestion, GmbH capital tracking, journal templates/Daueraufträge (accounting_invoices_router.py, accounting_expenses_router.py, accounting_reports_router.py, accounting_bank_router.py, all mounted alongside this router at the same /api/accounting prefix, app_fastapi.py 915 to 923) are separate routers, separate recipes. The /dashboard-data and /audit-log reads and the account-balance calculator (get_account_balance, engine.py 249 to 297) are read models over this data, not part of the write-side invariant, and are out of scope here.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for request/response schemas, psycopg2-binary==2.9.10, alembic==1.16.5, Postgres 15 in prod (docker-compose.prod.yml 9) / 14 in the test harness (docker-compose.test.yml 6). Foundations assumed: S4 (fastapi-project-layout) for the app and the router mount (mount_router(app, accounting_router.router, prefix="/api/accounting", tags=["Accounting"], surfaces=LABS_SURFACES), app_fastapi.py 915); S5 (auth-dependency-pattern) for ActiveUser; S6 (workspace-scoping) for WorkspaceID (_get_workspace_id); every query in this recipe is filtered by it; S8 (alembic-migrations) for the single chained migration under Deploy & ops; S9 (test-layout) for the fastapi.testclient.TestClient pattern the coverage suite uses.

No environment variable gates this recipe (grep of both chart_of_accounts.py and engine.py finds none) and no feature flag either; chart-of-accounts CRUD and the engine's validators are always live wherever the accounting router is mounted, unlike a webhook seam in a sibling billing recipe that fails closed on a missing shared credential.

Data model

accounting_accounts (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id      uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  account_number    varchar(10) NOT NULL,
  name_de           varchar(255) NOT NULL,
  name_en           varchar(255),
  name_fr           varchar(255),
  name_it           varchar(255),
  account_type      accounttype NOT NULL,        -- asset | liability | equity | revenue | expense
  parent_id         uuid REFERENCES accounting_accounts(id) ON DELETE SET NULL,
  is_header         boolean DEFAULT false,        -- header accounts group children, cannot receive postings
  is_active         boolean DEFAULT true,
  vat_code          varchar(10),
  currency          varchar(3) DEFAULT 'CHF',
  description       text,
  sort_order        integer DEFAULT 0,
  created_at        timestamptz DEFAULT CURRENT_TIMESTAMP,
  updated_at        timestamptz,
  deleted_at        timestamptz                   -- soft delete; NOT consulted by validate_accounts, see Gotchas
);
UNIQUE INDEX ix_accounting_accounts_ws_number (workspace_id, account_number)   -- unconditional, NOT partial on deleted_at, see Gotchas
INDEX ix_accounting_accounts_ws_parent (workspace_id, parent_id)
INDEX ix_accounting_accounts_ws_type   (workspace_id, account_type)

accounting_fiscal_years (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id),
  name         varchar(50) NOT NULL,
  start_date   date NOT NULL,
  end_date     date NOT NULL,
  is_closed    boolean DEFAULT false,
  closed_at    timestamptz,
  closed_by    uuid REFERENCES users(id),
  created_at   timestamptz DEFAULT CURRENT_TIMESTAMP,
  updated_at   timestamptz
);
CHECK ck_accounting_fiscal_years_dates: start_date < end_date
INDEX ix_accounting_fiscal_years_ws_start  (workspace_id, start_date)
INDEX ix_accounting_fiscal_years_ws_closed (workspace_id, is_closed)

The two tables this recipe's invariant is checked against but does not own the write path for (journal_service.py, a neighboring recipe, cited for the shape the invariant protects):

accounting_journal_entries (
  id, workspace_id, fiscal_year_id REFERENCES accounting_fiscal_years(id),
  entry_number integer,          -- assigned on POST only, gap-free
  entry_date date, posting_date date, description varchar(500), reference varchar(100),
  status journalentrystatus DEFAULT 'draft',   -- draft | posted | void
  source_type varchar(50), source_id uuid, posted_at, posted_by, voided_at, voided_by, void_reason,
  receipt_file_path varchar(500), created_by uuid NOT NULL, created_at, updated_at, deleted_at
);
UNIQUE (workspace_id, fiscal_year_id, entry_number)

accounting_journal_entry_lines (
  id, journal_entry_id REFERENCES accounting_journal_entries(id) ON DELETE CASCADE,
  account_id REFERENCES accounting_accounts(id), line_number integer NOT NULL, description varchar(255),
  debit_amount numeric(15,2) NOT NULL DEFAULT 0, credit_amount numeric(15,2) NOT NULL DEFAULT 0,
  currency varchar(3) DEFAULT 'CHF', exchange_rate numeric(12,6) DEFAULT 1.000000,
  vat_code varchar(10), vat_rate numeric(5,2), vat_amount numeric(15,2), cost_center varchar(50),
  created_at, updated_at
);
CHECK ck_jel_amounts_non_negative: debit_amount >= 0 AND credit_amount >= 0
CHECK ck_jel_debit_or_credit:      debit_amount = 0 OR credit_amount = 0

(models/accounting_models.py 158 to 224; migration lines 85 to 138.)

What is NOT stored, and NOT enforced by the database: there is no CHECK constraint anywhere in the schema requiring an entry's lines to sum to equal debit and credit totals (models/accounting_models.py 219 to 224 and migration lines 135 to 136 create only the two per-line CHECKs above, nothing entry-level). The balance invariant is enforced exclusively in application code; DoubleEntryEngine.validate_lines, called from journal_service.create_entry/update_entry; plus, redundantly and independently, a second copy of the same balance check written directly into the JournalEntryCreate Pydantic schema (schemas/accounting_schemas.py 177 to 186, see Application logic). A row written directly (a migration, a data fix, any future caller that bypasses journal_service) is not stopped by the database from being unbalanced.

API surface

RouteAuthTenancyCodesNotes
GET /api/accounting/accounts (accounting_router.py 494 to 496)ActiveUserWorkspaceID200 List[AccountResponse]get_account_tree (chart_of_accounts.py 155 to 167): flat list, deleted_at IS NULL, ordered sort_order, account_number; the frontend builds the tree itself
POST /api/accounting/accounts (499 to 501)ActiveUserWorkspaceID201; 409 duplicatecreate_account (183 to 205): 409 if account_number already exists for the workspace among non-deleted rows
GET /api/accounting/accounts/{account_id} (504 to 506)ActiveUserWorkspaceID200; 404get_account (169 to 181): 404 if missing, soft-deleted, or belongs to another workspace
PATCH /api/accounting/accounts/{account_id} (509 to 513)ActiveUserWorkspaceID200; 404update_account (217 to 224): only _ALLOWED_UPDATE_FIELDS applied, see Application logic
DELETE /api/accounting/accounts/{account_id} (516 to 519)ActiveUserWorkspaceID204; 404delete_account (226 to 231): soft delete only, sets deleted_at, does not touch is_active
POST /api/accounting/accounts/seed (522 to 525)ActiveUserWorkspaceID201 {"seeded": int}seed_default_chart (107 to 153): idempotent, 0 if the workspace already has any account row at all
GET /api/accounting/fiscal-years (397 to 406)ActiveUserWorkspaceID200 List[FiscalYearResponse]ordered start_date desc
POST /api/accounting/fiscal-years (409 to 423)ActiveUserWorkspaceID201; 422422 if end_date <= start_date (FiscalYearBase.end_after_start, schemas/accounting_schemas.py 76 to 87)
PATCH /api/accounting/fiscal-years/{fy_id} (426 to 442)ActiveUserWorkspaceID200; 404only non-None fields in FiscalYearUpdate applied; no re-check that a partial update keeps start_date < end_date before commit (the DB CHECK still catches it)
DELETE /api/accounting/fiscal-years/{fy_id} (445 to 467)ActiveUserWorkspaceID204; 400; 404400 if is_closed; 400 if any JournalEntry (own workspace) references it
POST /api/accounting/fiscal-years/{fy_id}/close (470 to 489)ActiveUserWorkspaceID200; 400; 404400 if already closed; no reopen endpoint exists on this router (grep -n "is_closed = False" accounting_router.py finds nothing)

Every route above requires ActiveUser and resolves WorkspaceID via _get_workspace_id; an unauthenticated call answers 400 or 401 depending on which sub-dependency FastAPI resolves first (WorkspaceID and get_current_user are sibling dependencies with no fixed order); the coverage suite's own fixture accepts either (UNAUTH_REJECTED = (400, 401), test file 113 to 119), and this recipe does the same rather than pretending one is authoritative.

Application logic

Seeding (seed_default_chart, chart_of_accounts.py 107 to 153), in commit order:

  1. Existence probe: any row at all for the workspace short-circuits the whole seed (108 to 116) and returns 0; idempotent against a re-seed, but coarse: a workspace with one manually-created account can never be auto-seeded, not only an already-fully-seeded one.
  2. First pass (123 to 144): for each of the 73 entries in DEFAULT_ACCOUNTS (measured 2026-09-12: len(DEFAULT_ACCOUNTS) == 73, all 73 account_number values unique, so the seen_numbers de-duplication guard at 124 to 126 is currently unreachable against this literal data), builds an AccountingAccount with sort_order = int(account_number) and adds it to the session; one db.flush() after the loop (144) to obtain real ids.
  3. Second pass (147 to 149): walks the same list again and sets parent_id on the children of the three header accounts (1000 Kasse groups Post/Bank/short-term investments; 4000 Materialaufwand groups merchandise cost/third-party services/inventory change; 5000 Personalaufwand groups wages/social insurance/other personnel cost/temp staff), then one db.commit() (151).
  4. Both passes share one session, so a crash between them rolls back together; there is no partial-seed state observable from another connection.

Account CRUD (chart_of_accounts.py):

  • create_account (183 to 205): one existence check scoped to workspace_id + account_number + deleted_at IS NULL (185 to 193), 409 on hit; else insert, commit, refresh.
  • update_account (217 to 224): loads via get_account (workspace-scoped, not-deleted, 404 otherwise), then applies a key only when it is in _ALLOWED_UPDATE_FIELDS (207 to 211, the same 13 fields AccountUpdate exposes) AND either its value is not None or the key is in _NULLABLE_FIELDS (213 to 215: parent_id, vat_code, description, name_fr, name_it, name_en); so account_type, is_header, is_active, currency, sort_order, account_number, name_de can never be nulled through this endpoint even by an explicit null in the payload; they are silently skipped, not rejected.
  • delete_account (226 to 231): soft delete only, deleted_at = datetime.now(timezone.utc); does not set is_active = False, and performs no check for existing journal entry lines (draft or posted) against the account.

The invariant (DoubleEntryEngine, engine.py):

  • validate_lines(lines) (33 to 61), a pure @staticmethod, no DB access: at least 2 lines (36 to 37); per line, debit_amount/credit_amount both non-negative (46 to 47), not both non-zero (48 to 49), not both zero (50 to 51); accumulate; total_debit != total_credit raises ValueError(f"Entry not balanced: debit={total_debit} credit={total_credit}") (56 to 59); total_debit == 0 also raises (60 to 61), though this branch is only reachable if every line was already individually rejected, since the per-line checks already forbid an all-zero line.
  • validate_accounts(db, lines, workspace_id) (63 to 89): every referenced account_id must exist in the workspace (76 to 79); a header account cannot be posted to (81 to 85); an inactive account cannot be posted to (86 to 89). Its query (67 to 74) does not filter deleted_at; see Gotchas.
  • validate_fiscal_year(db, fiscal_year_id, workspace_id, entry_date) (91 to 112): must exist in the workspace (103 to 104); must not be closed (105 to 106); entry_date must fall within [start_date, end_date] (107 to 111).
  • Call order and site (journal_service.create_entry, journal_service.py 27 to 40, a neighboring recipe, cited only for where this recipe's invariant is exercised): validate_lines first (37), then validate_accounts (39), then validate_fiscal_year (40); all three can raise before a single JournalEntry or JournalEntryLine row is constructed (42 onward). update_entry (133 to 185) re-runs only validate_lines and validate_accounts when lines are supplied (153 to 154), never validate_fiscal_year.
  • None of the three validators is caught anywhere on this path: they raise bare ValueError, journal_service wraps none of the three calls in try/except, and app_fastapi.py's registered exception handlers (551 to 814) map only HTTPException and a named list of AlchemyException subclasses to specific status codes; a bare ValueError falls through to the generic Exception handler (795 to 814) and answers 500 "Internal server error", not 422 or 400. The one balance failure that DOES surface as 422 on create is caught earlier and by a different mechanism: JournalEntryCreate.validate_balance (schemas/accounting_schemas.py 177 to 186), a Pydantic field_validator that duplicates the balance half of validate_lines and runs during request parsing, before the route body executes at all. Because the schema always runs first and both checks agree on a balanced/unbalanced verdict, engine.validate_lines's own balance branch is not reachable as an independently observable failure through POST /journal-entries today; the schema, not the engine, is what the API actually returns 422 for on create. (update_entry's JournalEntryUpdate schema carries no such validator, so there validate_lines is the only gate; see Gotchas for what that gate then does to the response.)

Invariants

  • A journal entry's lines always sum to equal debit and credit totals before any JournalEntryLine row is added to the session; enforced by validate_lines (tests/accounting/test_engine.py::TestValidateLines, 8/8 passing, measured 2026-09-12).
  • A line can never carry both a debit and a credit amount, and never carries zero of both (validate_lines 46 to 51; test_both_debit_and_credit_fails, test_zero_amount_entry_fails).
  • A posting can never target a header account or an inactive account (validate_accounts 81 to 89). It CAN target a soft-deleted one: validate_accounts's query (67 to 74) does not filter deleted_at, and delete_account never sets is_active = False, so deleting an account from the chart of accounts does not, by itself, make it unpostable; read directly from both source files, 2026-09-12; neither given test file exercises this path, so this is stated as a source-verified fact, not a test result.
  • A posting can never fall on a date outside its fiscal year, or land in a closed one (validate_fiscal_year 105 to 111; exercised indirectly by test_post_into_closed_fiscal_year_rejected, which actually goes through post_entry's own re-validation rather than create_entry's, since the entry in that test is created before its fiscal year is closed).
  • Seeding is idempotent per workspace: a second POST /accounts/seed returns {"seeded": 0} and adds nothing (test_seed_chart_of_accounts, measured passing 2026-09-12).
  • account_number is meant to be unique per workspace among non-deleted accounts, but this is enforced by two mechanisms that disagree on scope: the application check in create_account is scoped to deleted_at IS NULL (185 to 193), while the database's own unique index (ix_accounting_accounts_ws_number, migration line 60) is unconditional and includes soft-deleted rows. Re-creating a number whose only prior holder was soft-deleted therefore passes the app-level check and then fails at the database with a raw IntegrityError, not the clean 409 the app-level check produces for a still-live duplicate; read from source (migration lines 39 to 61 vs. chart_of_accounts.py 185 to 198), not exercised by either given test file.

Frontend

bookkeeping/ui/bookkeeping-financial-dashboard   Prima's frontend module for this domain, authored in
                                                  parallel with this recipe; this recipe does not describe
                                                  its rendering. At 62ba4fc, alchemy_labs' own React admin
                                                  renders the equivalent surface as two existing pages, the
                                                  nearest study material for a rebuild: ChartOfAccounts.tsx
                                                  (169 lines, list/create/edit/soft-delete/seed against
                                                  accountingApi.ts 301-306) and the fiscal-year panel inside
                                                  AccountingSettings.tsx (1143 lines; listFiscalYears/
                                                  createFiscalYear/closeFiscalYear/deleteFiscalYear,
                                                  accountingApi.ts 295-298).

Minimum admin/debug surface: no dedicated debug route exists for this recipe (list_accounts/list_fiscal_years already are the admin view). Because the balance invariant has no database backing (Data model), the one query worth running by hand to prove it is holding in practice is: SELECT e.id, e.workspace_id FROM accounting_journal_entries e JOIN accounting_journal_entry_lines l ON l.journal_entry_id = e.id GROUP BY e.id, e.workspace_id HAVING SUM(l.debit_amount) <> SUM(l.credit_amount); expected zero rows always, since nothing in the schema forces that but journal_service is the only writer of this table.

Security & tenancy

Auth dependency: ActiveUser (get_current_active_user, S5) on every route in this recipe; no surface_exempt on this router (unlike a public webhook seam in a sibling billing recipe), mounted under LABS_SURFACES at /api/accounting. Unauthenticated calls are rejected with 400 or 401 depending on dependency-resolution order (test_account_routes_require_auth, test_fiscal_year_routes_require_auth, both measured passing 2026-09-12).

Tenancy: workspace_id comes only from WorkspaceID (_get_workspace_id, S6), never from the request body; AccountCreate/FiscalYearCreate carry no workspace_id field at all, so a client cannot set one even by trying. Every read and write in this recipe is filtered by it (e.g. chart_of_accounts.get_account, 169 to 181). A cross-tenant lookup by id answers 404, not 403: the row is filtered out of the query entirely, so a caller cannot distinguish "does not exist" from "exists in a workspace I cannot see" (test_get_account_cross_tenant_404, test_close_fiscal_year_cross_tenant_404, both measured passing, and the latter additionally asserts the victim row's is_closed is unchanged).

Fail direction: closed on tenancy (an unknown or foreign id never returns another workspace's data); open on the engine's own validators past the API boundary; validate_accounts/validate_fiscal_year raise bare ValueError, which the generic exception handler turns into a 500 whose body is a fixed "Internal server error" string with no tenant-specific detail (app_fastapi.py 804 to 812), so nothing leaks, but the status code is wrong for a client trying to handle the error (Application logic, Gotchas).

Secrets and PII at rest: none held by this recipe specifically; account names/numbers and fiscal-year names are business configuration, not secrets; the only identifiers stored are closed_by/created_by user ids for audit attribution. Rate limits: none dedicated to these routes, same posture as the rest of /api/accounting. Attacker-controlled inputs: account_number (free text up to 10 chars, checked only for per-workspace uniqueness, not format-validated; the Swiss numeric convention is convention, not schema); debit_amount/credit_amount on lines reaching validate_lines (checked non-negative and mutually exclusive, not range-capped; no maximum amount); entry_date/start_date/end_date (bounded only by validate_fiscal_year's range check and FiscalYearBase.end_after_start, not by any sanity bound against, say, the year 9999).

Tests

Run 2026-09-12 against Postgres 14 (matching the test harness pin) on localhost:5434, using the venv at alchemy_labs/backend/.venv, against main at 62ba4fc:

python -m pytest tests/accounting/test_engine.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 8 passed (0 failed). Hermetic, no database touched at all; every case is a known-answer vector for validate_lines: balanced passes, unbalanced/zero-amount/single-line/negative/both-set all raise with a specific message substring, a 4-line balanced entry passes, and Decimal("1234.56") precision round-trips exactly.

TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/<fresh_scratch_db> \
    python -m pytest tests/v2/coverage/test_voila_g11_endpoint_layer_journal_entries_accounts_fiscal_years_auth_tenant_scoping_lifecycle_via_testclient_.py \
    -q --no-cov -o addopts="" -p no:cacheprovider

prints 37 passed, 2 failed on a freshly created, empty scratch database (39 def test_ total in the file; TestClient builds the schema itself via create_all() on first use, core.database.init_db()'s pytest branch). The two failures are test_journal_entry_post_cross_tenant_404 and test_journal_entry_void_cross_tenant_blocked; both outside this recipe's scope (they exercise engine.post_entry/void_entry's own bare ValueError("Journal entry not found") when a cross-tenant caller targets an id that is not in their workspace; the isolation invariant itself holds in both; the victim row is provably unposted/unvoided afterward; only the HTTP status is wrong, 500 instead of 404, see Gotchas). All 16 tests that ARE this recipe's scope pass cleanly: the 8 fiscal-year tests (test_create_fiscal_year_201, test_create_fiscal_year_validation_error, test_patch_fiscal_year, test_close_fiscal_year_sets_is_closed, test_delete_empty_fiscal_year_204, test_close_fiscal_year_cross_tenant_404, test_fiscal_year_routes_require_auth, and the re-close-is-400 assertion folded into test_close_fiscal_year_sets_is_closed) and the 8 account tests (test_create_account_201, test_create_account_duplicate_409, test_get_account, test_patch_account, test_delete_account_204_soft_delete, test_seed_chart_of_accounts, test_get_account_cross_tenant_404, test_account_routes_require_auth).

Running the same command against a Postgres database that already had other suites' state in it (rather than a fresh one) instead produced 19 errors and 2 failures; tests/v2/coverage/conftest.py's own docstring already warns that the full-app model graph "does not always round-trip cleanly between separate pytest invocations" over a schema that is not freshly built; a clean database is what reproduces the 37 passed, 2 failed figure above (both runs measured 2026-09-12, identical code, identical commit).

Hermetic vs live: both runs above are local-only, no external network, no PSP, no email. What a new app adds and this measured slice lacks: no dedicated test for validate_accounts accepting a soft-deleted (not merely inactive) account; stated from reading the source (Application logic), not from a failing test in either given file; no test asserts the unconditional unique index actually raises IntegrityError on account_number reuse after a soft delete.

Deploy & ops

Config keys: none specific to this recipe; no environment variable gates chart-of-accounts CRUD or the three validators (Stack assumptions). Migration: one file, migrations/versions/20260214_add_accounting_tables.py (revision 20260214_accounting_tables, down-revision 20260206_webhook_events), creates accounting_accounts (39 to 62) and accounting_fiscal_years (65 to 82); unlike a sibling billing recipe's four chained migrations for one table, this feature shipped as a single 455-line file that also creates seven more accounting tables out of this recipe's scope (accounting_journal_entries/accounting_journal_entry_lines at 85 to 138, invoices/expenses/audit-log/etc. beyond that). No scheduler, no background job; everything in this recipe is synchronous request/response.

Argus-style check: POST /api/accounting/accounts/seed on a workspace that already has one account of any kind (not necessarily a full seed) answers {"seeded": 0}, proving idempotency without risking a duplicate chart. Given there is no database CHECK backing the balance invariant, the admin query under Frontend (HAVING SUM(debit_amount) <> SUM(credit_amount), expected zero rows) is a reasonable steady-state check to run periodically. Rollback: the migration's downgrade() drops the tables it created; a soft-deleted account is unaffected by a code rollback, and nothing in this recipe's write paths is destructive beyond the schema drop itself on a full downgrade().

Gotchas

  • 2026-09-12 (measured, both source files): validate_accounts's query (engine.py 67 to 74) does not filter deleted_at, and delete_account (chart_of_accounts.py 226 to 231) never sets is_active = False. A soft-deleted chart-of-accounts entry therefore remains fully postable through validate_accounts unless someone separately deactivates it. This is read from the code, not proven by a passing or failing test; neither given test file exercises a post against a soft-deleted account.
  • 2026-09-12 (measured, live run against a fresh scratch database): test_journal_entry_post_cross_tenant_404 and test_journal_entry_void_cross_tenant_blocked both fail their own assert r.status_code == 404 against an actual 500. Cause: engine.post_entry/void_entry raise a bare ValueError("Journal entry not found") when the workspace-scoped lookup misses, journal_service does not catch it, and nothing in app_fastapi.py's exception handlers (551 to 814) maps a bare ValueError to a 4xx, so it falls through to the generic Exception handler and answers 500. The test file's own comment at line 629 to 631 already documents the same pattern for posting into a closed fiscal year: it notes the router does not catch the ValueError so the call surfaces as a 500, but says the legal invariant (the entry stays unposted) must hold regardless of the HTTP code. The tenant-isolation guarantee itself is intact in all three cases (the victim row is provably untouched); only the HTTP status code is wrong.
  • 2026-09-12 (measured, both source files): the balance invariant has two independent, hand-written copies that happen to agree; DoubleEntryEngine.validate_lines (engine.py 34 to 61) and JournalEntryCreate.validate_balance (schemas/accounting_schemas.py 177 to 186); and only the schema copy is reachable as an observable 422 through POST /journal-entries, since Pydantic validates the request body before the route handler, hence before journal_service.create_entry, ever runs. validate_lines's own balance branch is exercised on PATCH /journal-entries/{id} instead, where no schema-level balance check exists (JournalEntryUpdate has none); and there, per the point above, a failure is an unguarded ValueError that surfaces as 500, not 422.
  • 2026-09-12 (measured, migration file vs. service file): ix_accounting_accounts_ws_number (migration line 60) is an unconditional unique index on (workspace_id, account_number), not partial on deleted_at IS NULL, while create_account's own pre-check (chart_of_accounts.py 185 to 193) IS scoped to deleted_at IS NULL. Re-creating a soft-deleted account's number passes the app-level check and then raises a raw IntegrityError at the database rather than the clean 409 a live duplicate produces. Not exercised by either given test file.
  • 2026-09-12 (measured, both live pytest runs): the 39-test coverage file only produces the clean 37 passed, 2 failed split on a database created fresh for the run; against a Postgres container that already had other suites' schema/state in it, the identical command and identical code instead produced 19 errors and 2 failures. tests/v2/coverage/conftest.py's own docstring already names this risk. None of the 19 were a real behavior change; same commit, same source.
  • 2026-09-12 (measured, accounting_router.py): audit_service is imported into this router but its log_action is called only from journal_service.py (create/update/delete/post/void of journal entries); nothing in the accounts or fiscal-years CRUD paths writes an audit-log row. Editing or soft-deleting a chart-of-accounts entry, or closing a fiscal year, leaves no trace in GET /api/accounting/audit-log.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12) unless noted.

  • alchemy_labs/backend/services/accounting/chart_of_accounts.py (234 lines, whole file in scope): module docstring (1 to 4); DEFAULT_ACCOUNTS (18 to 102, 73 entries, 3 header accounts: 1000/4000/5000); ChartOfAccountsService.seed_default_chart (107 to 153); get_account_tree (155 to 167); get_account (169 to 181); create_account (183 to 205); _ALLOWED_UPDATE_FIELDS/_NULLABLE_FIELDS (207 to 215); update_account (217 to 224); delete_account (226 to 231).
  • alchemy_labs/backend/services/accounting/engine.py (300 lines, whole file cited; this recipe's scope is the three validators): module docstring (1 to 4); DEBIT_NORMAL_TYPES/CREDIT_NORMAL_TYPES (24 to 25); DoubleEntryEngine.validate_lines (33 to 61); validate_accounts (63 to 89); validate_fiscal_year (91 to 112); assign_entry_number/post_entry/void_entry/get_account_balance (116 to 297, neighboring recipe, cited for context and for the bare-ValueError pattern in Gotchas).
  • alchemy_labs/backend/api/routers/accounting_router.py (1052 lines): imports (1 to 37); fiscal-years block (397 to 490); chart-of-accounts block (492 to 526); journal-entries/dashboard/audit-log/templates/receipts/telegram blocks (67 to 1052, neighboring recipes, not detailed here).
  • alchemy_labs/backend/services/accounting/journal_service.py: create_entry (27 to 81, validator call sites at 37/39/40); update_entry (133 to 185, validator call sites at 153 to 154); neighboring recipe, cited only for where this recipe's invariant is actually exercised.
  • alchemy_labs/backend/models/accounting_models.py: AccountType (22 to 28); JournalEntryStatus (31 to 35); AccountingAccount (103 to 133); AccountingFiscalYear (136 to 155); JournalEntry (158 to 192); JournalEntryLine (195 to 224).
  • alchemy_labs/backend/schemas/accounting_schemas.py: AccountBase/AccountCreate/AccountUpdate/AccountResponse (22 to 69); FiscalYearBase/Create/Update/Response (76 to 109); JournalEntryCreate.validate_balance (174 to 186, the schema-level duplicate of validate_lines's balance check).
  • alchemy_labs/backend/migrations/versions/20260214_add_accounting_tables.py (455 lines): revision 20260214_accounting_tables, down-revision 20260206_webhook_events; accounting_accounts table and indexes (39 to 62); accounting_fiscal_years table, CHECK, and indexes (65 to 82); accounting_journal_entries/accounting_journal_entry_lines (85 to 138, neighboring recipe).
  • alchemy_labs/backend/app_fastapi.py: router mount (915); generic exception handler (795 to 814, the reason a bare ValueError becomes 500).
  • alchemy_labs/frontend/src/pages/accounting/ChartOfAccounts.tsx (169 lines) and AccountingSettings.tsx (1143 lines, fiscal-year panel), alchemy_labs/frontend/src/services/accountingApi.ts (295 to 306); the existing frontend this recipe's frontend_modules entry would be rebuilding, cited for context only.
  • Tests: alchemy_labs/backend/tests/accounting/test_engine.py (77 lines, whole file); alchemy_labs/backend/tests/v2/coverage/test_voila_g11_endpoint_layer_journal_entries_accounts_fiscal_years_auth_tenant_scoping_lifecycle_via_testclient_.py (836 lines; this recipe's slice is lines 321 to 478, plus the two failing tests at 649 to 676 cited in Gotchas).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the balance invariant has no database-level backing, only an application-level check plus an independently-written schema-level duplicate that happens to agree; validate_accounts does not exclude soft-deleted accounts; every bare ValueError the engine's own guard functions raise is unmapped to any 4xx and surfaces as 500; the account_number uniqueness index is unconditional while the matching application check is scoped to non-deleted rows, a latent IntegrityError; accounts and fiscal-years CRUD write no audit-log entry, unlike journal-entry CRUD on the same router.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe), with a Postgres 14 instance reachable and a freshly created scratch database (Gotchas: a reused database with other suites' state produces noise, not signal). Expected observations are exact, as measured 2026-09-12.

  1. python -m pytest tests/accounting/test_engine.py -q --no-cov -o addopts="" -p no:cacheprovider prints 8 passed.
  2. createdb -h localhost -p 5434 -U test_user <a_new_scratch_db_name> (or the equivalent for the local Postgres) creates an empty database; no separate alembic upgrade head step is required to exercise these two tables, since the coverage suite imports app_fastapi and builds the whole model graph itself via create_all() on first use.
  3. TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/<that_new_db> python -m pytest tests/v2/coverage/test_voila_g11_endpoint_layer_journal_entries_accounts_fiscal_years_auth_tenant_scoping_lifecycle_via_testclient_.py -q --no-cov -o addopts="" -p no:cacheprovider prints 37 passed, 2 failed; the 2 failures are exactly test_journal_entry_post_cross_tenant_404 and test_journal_entry_void_cross_tenant_blocked (both a 500-vs-404 mismatch, Gotchas), never any fiscal-year or account test.
  4. grep -c "^def test_" tests/v2/coverage/test_voila_g11_endpoint_layer_journal_entries_accounts_fiscal_years_auth_tenant_scoping_lifecycle_via_testclient_.py prints 39.
  5. From the backend venv: python -c "from services.accounting.chart_of_accounts import DEFAULT_ACCOUNTS; print(len(DEFAULT_ACCOUNTS), len(set(a[0] for a in DEFAULT_ACCOUNTS)))" prints 73 73; 73 default accounts, no duplicate account numbers.
  6. grep -n "def validate_lines\|def validate_accounts\|def validate_fiscal_year" services/accounting/engine.py prints lines 34, 64, 92.
  7. grep -n "workspace_id" services/accounting/chart_of_accounts.py | wc -l prints a count greater than 15 (every method threads workspace_id through its query), confirming there is no code path in this file that queries AccountingAccount without a workspace filter.
  8. grep -n "server_default=sa.text('gen_random_uuid()')" migrations/versions/20260214_add_accounting_tables.py | head -1 shows it on the accounting_accounts.id column (line 41), confirming ids are generated by the database, not solely by the ORM default.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.