Prima Get an API key

Module kit / Bookkeeping

Journal entries, posting lifecycle and recurring templates

bookkeeping/journal-entries-and-recurring-templates

Kind backend
Domain bookkeeping
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, alembic-1.16, apscheduler-3.11, python-dateutil-2.9
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/scheduler-and-background-work, _shared/test-layout
Frontends bookkeeping/ui/bookkeeping-financial-dashboard
Reference implementation
  • alchemy_labs/backend/services/accounting/journal_service.py
  • alchemy_labs/backend/services/accounting/engine.py
  • alchemy_labs/backend/api/routers/accounting_router.py
  • alchemy_labs/backend/models/accounting_models.py
  • alchemy_labs/backend/schemas/accounting_schemas.py
  • alchemy_labs/backend/services/accounting/audit_service.py
  • alchemy_labs/backend/services/scheduled_jobs.py
  • alchemy_labs/backend/migrations/versions/20260214_add_accounting_tables.py
  • alchemy_labs/backend/migrations/versions/20260223_add_standing_order_fields.py
  • alchemy_labs/backend/tests/v2/coverage/test_voila_g2_journal_service_crud_lifecycle_draft_only_edit_delete_bulk_post_service_level_db_.py
  • alchemy_labs/backend/tests/v2/coverage/test_voila_g3_journal_templates_standing_orders_dauerauftr_ge_service_level_db_.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A workspace books a double-entry draft, edits it while it is still a draft, and posts it (singly or in bulk) to receive a gap-free sequential number within its fiscal year; posting is a one-way door, undoing a posted entry always creates a new reversing (Storno) entry rather than mutating the original, and a Dauerauftrag template can generate that same draft-then-post cycle on a monthly/quarterly/yearly schedule instead of by hand. The invariant that makes the ledger trustworthy is that post_entry re-checks the balance and the fiscal year's open/closed state and re-locks the fiscal year row (FOR UPDATE, engine.py 120-127) immediately before minting the entry number, so no draft can be posted out of a closed period or with a number that collides under concurrent posting, and nothing before that lock (creation, edits) ever touches entry_number (JournalEntry.entry_number stays NULL until post_entry assigns it, models/accounting_models.py 175).

When to use

Read this before touching journal_service.create_entry, update_entry, delete_entry, post_entry, void_entry, bulk_post, or the template methods create_template, update_template, delete_template, create_from_template, get_due_templates, generate_due_entries, _advance_next_due_date; or engine.validate_lines, validate_accounts, validate_fiscal_year, assign_entry_number, post_entry, void_entry. Trigger phrases: "post a journal entry", "void a booking", "Storno", "reversing entry", "draft only editable", "gap-free entry number", "bulk post", "Dauerauftrag", "standing order", "next_due_date", "generate due entries", "auto-post".

Not this recipe: POST /journal-entries/{id}/upload-receipt, POST /journal-entries/bulk-upload-receipts, GET /journal-entries/{id}/receipt, GET /journal-entries/pending-receipts (all in accounting_router.py, 588-803) and the OCR/extraction they call (mistral_extraction_service, ocr_service, pdf_receipt_service); the Telegram capture routes on the same file, POST /telegram/generate-code, GET /telegram/status, POST /telegram/disconnect (808-897) and telegram_bot_service; POST /templates/detect-patterns and POST /templates/accept-pattern (1003-1052) and pattern_detection_service, a later-wave concern per this recipe's own scope; POST /templates/{id}/upload-document (980-998), the Dauerauftrag's own base-document (contract/lease) attachment, adjacent to templates but not the posting lifecycle; every other accounting sub-area with its own service+router (invoices accounting_invoices_router.py, expenses accounting_expenses_router.py, bank reconciliation bank_reconciliation_service.py, VAT vat_service.py, capital capital_service.py, reports report_service.py) - separate recipes on the same domain; the frontend surface that renders any of this, bookkeeping/ui/bookkeeping-financial-dashboard, a neighbouring recipe.

Stack assumptions

At 62ba4fc (requirements.txt): fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for the request/response schemas, alembic==1.16.5 for the two chained migrations under Data model, psycopg2-binary==2.9.10, python-dateutil==2.9.0.post0 (relativedelta, the only import in journal_service.py outside the stdlib/SQLAlchemy/FastAPI, used solely by _advance_next_due_date), APScheduler==3.11.0 for the standing-order cron job. Postgres 15 in prod (docker-compose.prod.yml 9) / 14 in the test harness (docker-compose.test.yml 116-122, postgres-test on host port 5434, database test_db, user/password test_user/test_password).

Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router, which mounts accounting_router.router unconditionally at /api/accounting (app_fastapi.py 915); S5 (auth-dependency-pattern) for ActiveUser (Annotated[User, Depends(get_current_active_user)], api/dependencies.py 566, 338-345); S6 (workspace-scoping) for WorkspaceID (_get_workspace_id, api/dependencies.py 680-779) - every route in this recipe takes workspace_id: WorkspaceID and every service method takes it as its first scoping argument, there is no other tenancy mechanism in this file; S8 (alembic-migrations) for 20260214_add_accounting_tables.py and 20260223_add_standing_order_fields.py; S12 (scheduler-and-background-work) for the APScheduler singleton run_accounting_standing_orders registers on (scheduled_jobs.py 305-312); S9 (test-layout) for the shared db_session/test_user pytest fixtures the CRUD-lifecycle test file (G2) uses as-is, and that the templates test file (G3) deliberately does NOT use - see Gotchas and Tests.

No dedicated environment variable gates this seam (grep -ni accounting core/config.py finds only ACCOUNTING_TELEGRAM_BOT_TOKEN, out of this recipe's scope). Fail behaviour is inconsistent across the surface, not a single designed posture: get_entry/get_template raise HTTPException(404) on a missing/cross-workspace/soft-deleted row (both closed); update_entry's status check and delete_entry's status check raise HTTPException(400) directly; but engine.validate_lines, validate_accounts, validate_fiscal_year, post_entry and void_entry all raise plain ValueError, which no router handler in this recipe catches - see Gotchas for what that means at the HTTP layer.

Data model

accounting_journal_entries, the entry header (models/accounting_models.py 158-193; created migrations/versions/20260214_add_accounting_tables.py 87-112):

accounting_journal_entries (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id     uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  fiscal_year_id   uuid NOT NULL REFERENCES accounting_fiscal_years(id),
  entry_number     integer,                    -- NULL until posted; gap-free per (workspace_id, fiscal_year_id)
  entry_date       date NOT NULL,               -- Belegdatum
  posting_date     date NOT NULL,               -- Buchungsdatum
  description      varchar(500) NOT NULL,
  reference        varchar(100),                -- "DA-<name>" (standing order) | "TPL-<name>" (plain template) | "VOID-<n>"
  status           journal_entry_status NOT NULL DEFAULT 'draft',   -- draft | posted | void
  source_type      varchar(50),                 -- 'standing_order' | 'void' | NULL
  source_id         uuid,                       -- the voided entry, when source_type='void'
  posted_at        timestamptz, posted_by uuid REFERENCES users(id) ON DELETE SET NULL,
  voided_at        timestamptz, voided_by uuid REFERENCES users(id) ON DELETE SET NULL,
  void_reason      text,
  receipt_file_path varchar(500),
  created_by       uuid NOT NULL REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz DEFAULT now(), updated_at timestamptz, deleted_at timestamptz
);
UNIQUE (workspace_id, fiscal_year_id, entry_number)   -- uq_journal_entries_ws_fy_number, the gap-free guarantee
INDEX (workspace_id, entry_date); INDEX (workspace_id, status)

accounting_journal_entry_lines (196-225; 117-138): id uuid PK, journal_entry_id uuid NOT NULL REFERENCES accounting_journal_entries(id) ON DELETE CASCADE, account_id uuid NOT NULL REFERENCES accounting_accounts(id), line_number integer NOT NULL, description varchar(255), debit_amount numeric(15,2) NOT NULL DEFAULT 0.00, credit_amount numeric(15,2) NOT NULL DEFAULT 0.00, 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). 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) - the database enforces "never both" per line, but NOT "never neither" and NOT the cross-line balance; those two are enforced only in Python (engine.validate_lines, 34-61: at least 2 lines, no negative amounts, no line with both, no line with neither, sum(debit) == sum(credit), total not zero) and nowhere at the database level.

accounting_journal_templates, one row per template OR standing order - the same table, distinguished only by whether category is set (228-256; 143-157, extended 20260223_add_standing_order_fields.py): id uuid PK, workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, name varchar(255) NOT NULL, description text, template_lines json DEFAULT '[]' (a JSON-encoded copy of the line shape above, account ids included - see Gotchas for what that JSON round-trip breaks), frequency template_frequency DEFAULT 'manual' (monthly|quarterly|yearly|manual), last_generated date, is_active boolean DEFAULT true, category varchar(50) (salary|rent|insurance|subscription|loan|other, freeform per the model comment, not a DB enum - whatever value is passed through is what makes a template a "standing order" for the DA-/TPL- branch), counterparty varchar(255), amount numeric(15,2), base_document_path varchar(500), next_due_date date, end_date date, auto_post boolean DEFAULT false, created_at/updated_at/deleted_at. INDEX (workspace_id), INDEX (workspace_id, next_due_date), INDEX (workspace_id, category). No CHECK constraint ties category to next_due_date/frequency - a template with category set but no next_due_date is a legal row that simply never becomes due (get_due_templates filters next_due_date.isnot(None)).

What is NOT stored: a running account balance (engine.get_account_balance recomputes from posted lines every call, 249-297, the same no-stored-balance pattern as the auto-topup recipe's ledger); any distinction in the schema between a "template" and a "standing order" beyond category IS NOT NULL - there is no is_standing_order column, the router/service infer it from that one field every time (journal_service.py 266).

API surface

All routes are under /api/accounting (app_fastapi.py 915), auth ActiveUser, tenancy WorkspaceID (both S5/S6, closed-fail). accounting_router.py:

RouteStatus codesNotes
GET /journal-entries (530-544)200filters: fiscal_year_id, entry_status, start_date, end_date, limit/offset (default 100/0)
POST /journal-entries (547-552)201, 422 (Pydantic: unbalanced/empty lines rejected before the service is even called, JournalEntryCreate.validate_balance, accounting_schemas.py 177-186)body JournalEntryCreate (fiscal_year_id, entry_date, posting_date, description, reference?, lines: [JournalEntryLineCreate] (min 2))
POST /journal-entries/bulk-post (555-558)200 always (partial success is in the body, never a 4xx/5xx)body BulkPostRequest{entry_ids} → BulkPostResponse{posted: [uuid], errors: [{entry_id, error}]}
GET /journal-entries/{id} (619-621)200, 404
PATCH /journal-entries/{id} (624-630)200, 400 (not draft), 500 (unbalanced replacement lines - see Gotchas)body JournalEntryUpdate, all fields optional, lines fully replaces if present
DELETE /journal-entries/{id} (633-636)204, 400 (posted; message says "Use void instead")soft-delete only, draft only
POST /journal-entries/{id}/post (639-641)200, 500 (wrong status / unbalanced / fiscal year closed - see Gotchas)no body
POST /journal-entries/{id}/void (644-646)200, 500 (not posted)body JournalEntryVoidRequest{reason}
GET /templates (915-917)200
GET /templates/{id} (920-922)200, 404
POST /templates (925-929)201body JournalTemplateCreate
PATCH /templates/{id} (932-936)200, 404exclude_unset=True - an explicit null still overwritten only when service's own if value is not None allows it (see Application logic)
DELETE /templates/{id} (939-942)204, 404
POST /templates/{id}/generate (945-963)200, 404, 500 (template account ids - Gotchas)body GenerateDueRequest{fiscal_year_id}; single-template manual trigger, advances next_due_date itself (961)
POST /templates/generate-due (966-977)200 (per-template failures are swallowed, see Application logic)body GenerateDueRequest{fiscal_year_id} → GenerateDueResponse{generated, auto_posted, entries: [uuid]}

Every 500 marked above is not a deliberate status code choice; it is FastAPI's generic @app.exception_handler(Exception) (app_fastapi.py 795-813) catching an unhandled ValueError and returning {"error": "Internal server error", ...} - see Gotchas for the precise mechanics and why it matters.

Application logic

create_entry (journal_service.py 27-81): engine.validate_lines (balance + shape) → engine.validate_accounts (every account_id exists in this workspace, is not is_header, is is_active) → engine.validate_fiscal_year (exists, not is_closed, entry_date within [start_date, end_date]) → insert JournalEntry(status=draft) and flush to get its id → insert each JournalEntryLine with line_number = i+1 → flush → audit_service.log_action(..., "create") → commit → refresh.

update_entry (133-185): get_entry (404-scoped) → if entry.status != draft: raise HTTPException(400, "Can only edit draft entries") - the one status gate in this recipe that IS a clean HTTPException. If lines_data given: engine.validate_lines + validate_accounts (no validate_fiscal_year re-check on edit - an edit can move entry_date without re-validating it still falls inside the fiscal year, until the next post_entry catches it) → every existing line is db.delete()d and flushed before the new ones are added (157-159) - full replace, not a diff, so line ids are never stable across an edit (test_update_entry_draft_replaces_lines asserts old_line_ids.isdisjoint(new_line_ids)).

delete_entry (187-200): if entry.status != draft: raise HTTPException(400, "Cannot delete posted entries. Use void instead."); otherwise sets deleted_at = now(UTC) and commits - soft delete, the row (and its lines, all, delete-orphan only on the ORM relationship, not on the row itself) survives.

post_entry (202-209, delegates to engine.post_entry 140-178): lock the fiscal year row FOR UPDATE (via assign_entry_number, 120-127) → re-verify status == draft and len(lines) >= 2, else ValueError → re-sum debit/credit from the live rows and re-ValueError if unbalanced (a second check, independent of the one at create/edit time) → validate_fiscal_year again (catches a fiscal year closed AFTER the draft was created) → entry_number = coalesce(max(entry_number), 0) + 1 scoped to (workspace_id, fiscal_year_id) (128-136) → status = posted, posted_at, posted_by → commit + refresh (only when called with auto_commit=True, the router path; void_entry's internal re-post uses auto_commit=False and commits once at the end of its own transaction, see below) → journal_service.post_entry additionally writes the "post" audit row and commits again.

void_entry (211-218, delegates to engine.void_entry 180-245): status != posted → ValueError. In ONE transaction: mark the original status=void, voided_at, voided_by, void_reason; build a new draft JournalEntry(source_type="void", source_id=<original id>, reference=f"VOID-{original.entry_number}", description=f"Storno: {original.description}") with every line reversed (debit_amount ↔ credit_amount swapped, vat_amount negated - but -line.vat_amount if line.vat_amount else None, so a 0/None VAT amount stays None, never becomes -0); flush, refresh, then immediately call engine.post_entry(..., auto_commit=False) on the reversal so it is born already posted; commit once, covering the void AND the reversal's post in the same transaction. journal_service.void_entry then writes the "void" audit row with changes={"reason": reason} and commits again (a second, separate commit - the audit row is NOT in the same transaction as the void+reversal).

bulk_post (220-230): loops entry_ids, calls self.post_entry (the JournalService method, so each iteration gets its own audit row + its own commit) inside a per-id try/except Exception; a failure appends {"entry_id": str(eid), "error": str(e)} and moves on - one bad id never aborts the rest, and an id from another workspace fails the same way a nonexistent id does (get/post_entry is workspace-scoped, so it raises "not found", string-matched into the same error list - test_bulk_post_cross_workspace_entry_errors).

create_from_template (244-295): loads the template (404-scoped) → is_standing_order = template.category is not None → reference = f"DA-{name}" (standing order) or f"TPL-{name}" (plain) → calls create_entry with the template's template_lines as the new entry's lines → after create_entry's own commit, sets entry.source_type (only if standing order) and entry.receipt_file_path = template.base_document_path (only if set), advances template.last_generated = entry_date, commits AGAIN and refreshes - two commits, the second layered on top of create_entry's own.

generate_due_entries (421-471, the scheduler's entrypoint): get_due_templates (next_due_date <= as_of_date AND is_active AND not deleted AND (end_date IS NULL OR end_date >= as_of_date), 395-419) → per template, captures entry_date = template.next_due_date and calls _advance_next_due_date BEFORE create_from_template - the code comment (439-442) is explicit about why: create_from_template commits, so the entry and the advanced next_due_date land in the same transaction, and a failure mid-template rolls both back so the next run cannot double-generate a committed entry; a per-template try/except (460-462) calls db.rollback() on failure and continues to the next template, logging via logger.exception; if template.auto_post, post_entry is attempted in its own nested try/except (453-458) whose failure is only logged, never re-raised - a template can auto-generate a perfectly good draft and then silently fail to auto-post it, and generate_due_entries's return value gives no signal of that (auto_posted just stays short of generated).

_advance_next_due_date (473-492): monthly → +1 month, quarterly → +3 months, yearly → +1 year (all via dateutil.relativedelta), manual → next_due_date = None (a manual template never re-arms itself); after advancing, if end_date is set and the new next_due_date > end_date, the template is deactivated (is_active = False) and next_due_date is cleared in the same call.

Invariants

  • entry_number is assigned exactly once, under the fiscal year's row lock, and never reassigned or reused - nothing in this file UPDATEs entry_number outside assign_entry_number (grep -n "entry_number =" finds it only in engine.py 168).
  • A posted or void entry is never edited or hard-deleted by this service - update_entry and delete_entry both gate on status == draft before touching a row (test_update_entry_posted_is_rejected_400, test_delete_entry_posted_is_rejected_400).
  • Voiding never rewrites history: the original row keeps its entry_number, its lines, and gains only status=void/voided_*/void_reason; the reversal is a distinct row linked by source_type="void"/source_id (test_void_entry_marks_void_and_audits_reason).
  • A balanced-lines check runs at least once on every path that can create or replace lines (create_entry, update_entry, post_entry's own re-sum) - but see Gotchas for the one path where the check runs and still cannot succeed.
  • Tenant scoping is enforced by construction, not by an extra filter a caller could forget: every service method takes workspace_id and every query is WHERE ... workspace_id == workspace_id (get_entry 87-93, get_template 384-389, bulk_post's cross-workspace id simply fails to resolve); five tests in G2/G3 assert a cross-workspace id 404s or errors rather than leaking (test_get_entry_cross_workspace_raises_404, test_bulk_post_cross_workspace_entry_errors, test_get_template_cross_workspace_404, test_update_template_cross_workspace_404, test_create_from_template_cross_workspace_404).
  • The audit trail is append-only and written from inside the same service call that performs the mutation, never as an afterthought bolted onto the router (audit_service.log_action is called from create_entry, update_entry, post_entry, void_entry, create_template, update_template, delete_template - not from delete_entry's caller but from delete_entry itself, 197-199).

Frontend

bookkeeping/ui/bookkeeping-financial-dashboard   the surface this lifecycle is rendered through - a
                                                  neighbouring recipe, authored separately, not
                                                  written yet at measurement time (no
                                                  modules/bookkeeping/ui/ directory exists in this
                                                  kit as of 2026-09-12); this recipe does not
                                                  describe its component tree or states, only that
                                                  it is the consumer of the API surface above

Minimum admin/debug surface: no dedicated admin route exists for this seam at 62ba4fc; the closest thing is GET /journal-entries/pending-receipts (out of this recipe's scope, Not this recipe) and the audit trail itself, GET /audit-log?entity_type=journal_entry (899-910, accounting_router.py, not journal-specific but the only place a "who posted/voided what" query surfaces today).

Security & tenancy

Auth dependency: ActiveUser (S5), every route in this recipe requires a signed-in, active user; no route here is surface_exempt. Tenancy: WorkspaceID (S6), resolved from request.state.workspace_id (JWT middleware) or the X-Workspace-ID header, re-validated against the user's access on every request, fail-closed (403 on a workspace the user cannot access, 400/409 with no header and zero/multiple workspaces) - this recipe adds no tenancy logic of its own, it inherits S6 entirely and every service method's first real argument is the already-validated workspace_id.

Fail direction: inconsistent, not deliberately closed everywhere. get_entry/get_template/update_entry(status)/delete_entry(status) fail closed with a clean 4xx. post_entry, void_entry, and engine.validate_lines/validate_accounts/validate_fiscal_year fail via uncaught ValueError → the app's generic exception handler → HTTP 500 with a body that deliberately hides the reason ("An unexpected error occurred. Please contact support with the request ID", app_fastapi.py 804-812) - a legitimate business rejection (wrong status, closed fiscal year, unbalanced replacement lines) is indistinguishable, to the caller, from a genuine server fault. See Gotchas.

Secrets/PII at rest: none introduced by this recipe - no card data, no external credential; receipt_file_path and base_document_path are filesystem/Drive paths, not secrets, and both are out of this recipe's scope (receipt upload, Not this recipe). Rate limits: none dedicated to any route in this table. Attacker-controlled inputs, all behind ActiveUser+WorkspaceID: every field of JournalEntryCreate/JournalEntryUpdate/JournalTemplateCreate/JournalTemplateUpdate, none of which can escape workspace scoping (an account_id or fiscal_year_id from another workspace is rejected by validate_accounts/validate_fiscal_year's own WHERE workspace_id = :workspace_id, not by anything at the schema layer).

Tests

Run 2026-09-12 against the running postgres-test container (docker-compose.test.yml 114-123, port 5434, database test_db), from alchemy_labs/backend at 62ba4fc using .venv:

TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
  .venv/bin/python -m pytest \
  tests/v2/coverage/test_voila_g2_journal_service_crud_lifecycle_draft_only_edit_delete_bulk_post_service_level_db_.py \
  -q --no-cov -o addopts="" -p no:cacheprovider

prints 21 passed (21 def test_ functions, all against the shared conftest.py db_session/test_user fixtures, S9). The template file needs its own invocation because it builds its own engine (see below):

TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
  .venv/bin/python -m pytest \
  tests/v2/coverage/test_voila_g3_journal_templates_standing_orders_dauerauftr_ge_service_level_db_.py \
  -q --no-cov -o addopts="" -p no:cacheprovider

prints 28 passed, 5 xfailed (34 def test_-prefixed functions in the file, one of which, test_user at line 200, is a @pytest.fixture, not a collected test - --collect-only confirms 33 tests collected; of those, 5 are @pytest.mark.xfail(strict=True)).

Hermetic vs live: both files run against a real Postgres, no PSP/HTTP mocking needed (nothing in this recipe calls out). G3's own module docstring (1-31) documents WHY it cannot reuse the shared test_engine fixture from tests/conftest.py: that fixture's Base.metadata.create_all() emits CREATE TYPE twice for two pairs of enum classes that share a Postgres type name across unrelated modules (tasktype, mediatype), which raises UniqueViolation and prevents ANY table from being created for tests that depend on it. G3 works around this with its own session-scoped engine bound to a private Postgres schema (g3_<pid>, 116-169) that creates only the 9 tables this recipe needs and each enum type exactly once. This is a genuine, dated, product-level test-infrastructure defect, not something this recipe's own code causes - reported here because it is measured, not assumed; it does not affect G2, which was run above, unmodified, against the shared fixtures, and passed clean, so the duplicate-enum failure is specific to whichever model-import order pulls in both colliding enum pairs, not universal.

Known-answer vectors: engine.validate_lines - 2-line minimum, no negative amount, no line with both debit and credit, no line with neither, sum(debit) == sum(credit), non-zero total (all exercised via create_entry's rejection tests, _balanced_lines/bad fixtures in G2). _advance_next_due_date - exact calendar math asserted per frequency (test_advance_monthly 2026-01-15 → 2026-02-15, test_advance_quarterly → 2026-04-15, test_advance_yearly → 2027-01-15, test_advance_manual_clears_due_date → None, test_advance_past_end_date_deactivates sets is_active=False and nulls the date).

What a new app adds and this recipe lacks: no route-level (TestClient/HTTP) test for any endpoint in the API surface table - both coverage files call journal_service/engine directly, service-level only, so the FastAPI dependency wiring (ActiveUser, WorkspaceID, the 500-vs-400 fail direction documented in Gotchas) is unexercised by an actual HTTP request in this pair of files; no concurrency test for assign_entry_number's row lock (unlike the auto-topup recipe's own two-thread proof of its claim-before-send lock) - the gap-free guarantee rests on the FOR UPDATE lock and the UNIQUE constraint, neither is proven under real concurrent posting here.

Deploy & ops

Config keys: none dedicated (Stack assumptions). Migrations, in chain order (backend/migrations/versions/): 20260214_add_accounting_tables.py (revision 20260214_accounting_tables, down-revision 20260206_webhook_events) creates accounting_journal_entries, accounting_journal_entry_lines, accounting_journal_templates among the accounting module's other tables in one file (whole-module migration, not one-table-per-file); 20260223_add_standing_order_fields.py (revision 20260223_standing_orders, down-revision 20260218_telegram_links) adds the seven Dauerauftrag columns and the two ws_due/ws_category indexes to accounting_journal_templates. Both are a single linear down-revision chain at this commit (not verified against a live alembic heads run - no alembic executable was available in this environment at measurement time; the chain was confirmed by reading revision/down_revision in both files, not by running the upgrade).

Scheduler: run_accounting_standing_orders (scheduled_jobs.py 1726-1793), cron hour=6 daily (305-312, replace_existing=True), iterates every workspace with at least one active, non-deleted, due template, resolves that workspace's current open fiscal year (skips the workspace entirely if none - no fiscal year, no generation, silently), resolves the workspace owner as the acting user (SELECT owner_id FROM workspaces WHERE id = :ws_id, raw SQL), calls generate_due_entries per workspace inside its own try/except so one workspace's failure does not stop the sweep, and logs a single summary line only when total_generated > 0. Duplicate-run safety is commit-durable, not lock-based: the code comment at 1738-1745 is explicit that advancing+committing next_due_date inside generate_due_entries is what keeps a second run (this cron racing the manual /templates/generate-due trigger) from re-generating the same due entry - there is no per-template lock.

Argus-style checks: none exist for this seam specifically at 62ba4fc (no dedicated health/monitoring query was found for journal posting or standing-order generation). Rollback: code-only; the two migrations both have downgrade() functions (op.drop_table/op.drop_column in reverse order) but rolling back the code without rolling back data leaves posted entries and their entry_number sequence untouched - nothing in this recipe deletes a posted row.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, engine.py 63-89, xfail evidence in test_voila_g3_...py 54-81, 483-757): generate_due_entries and create_from_template are broken for every template that has real template_lines. template_lines is a JSON column; every account_id inside it round-trips as a Python str. engine.validate_accounts builds found_ids = {a.id for a in accounts} from the ORM (real UUID objects) and then checks aid not in found_ids for each aid in the caller-supplied list - str(existing_uuid) not in {existing_uuid} is always True, so a perfectly valid, existing, active account is reported "not found in workspace" and the whole create/generate call raises, every time, for every workspace. Confirmed live on this exact commit: pytest tests/v2/coverage/test_voila_g3_...py prints 28 passed, 5 xfailed, and the 5 xfails are exactly the tests marked _TEMPLATE_ENTRY_BUG (test_create_from_template_standing_order_da_prefix, test_create_from_template_plain_template_tpl_prefix, test_create_from_template_attaches_base_document, test_generate_due_entries_generates_and_advances, test_generate_due_entries_auto_posts) - each asserts the CORRECT post-fix behavior and is strict=True, so the suite would fail loudly if the bug were silently fixed without updating the marker. The 06:00 cron (run_accounting_standing_orders) therefore currently fails, per-workspace, for every workspace that has a due template with lines, is caught by that job's own per-workspace try/except, and only shows up as a logged error, never a generated entry. This is a product bug, not a documentation gap; the fix belongs in engine.validate_accounts (compare str(a.id) to str(aid), or coerce aid to UUID before the membership test), not in this recipe.
  • 2026-09-12 (measured at 62ba4fc, app_fastapi.py 795-813, journal_service.py/engine.py throughout): business-rule rejections that originate as a plain ValueError (wrong status on post/void, unbalanced replacement lines on update_entry, an unknown/inactive/header account, a closed or out-of-range fiscal year) are NOT translated to a 4xx by any handler in this recipe's path - no router function here wraps the service call in try/except ValueError, so FastAPI's generic Exception handler returns a 500 with a deliberately opaque body. JournalEntryCreate's own Pydantic validator (accounting_schemas.py 177-186) happens to catch the unbalanced-lines case for plain POST /journal-entries as a clean 422, but that protection does not extend to PATCH /journal-entries/{id} (whose lines field has no matching validator on JournalEntryUpdate), nor to POST .../post, POST .../void, or either template-generation route. A caller integrating against this API should not read a 500 from /post or /void as "the server is broken" - it is very likely a legitimate, recoverable rejection wearing the wrong status code.
  • 2026-09-12 (measured at 62ba4fc, journal_service.py 220-230, 244-295, 421-471): three call sites layer a second db.commit() on top of a method that already committed internally (bulk_post calling post_entry in a loop; create_from_template committing again after create_entry's own commit to persist source_type/receipt_file_path/last_generated; generate_due_entries calling create_from_template, which itself double-commits, per template). None of this is observed to corrupt data - SQLAlchemy sync sessions tolerate multiple commits fine - but it means a single logical "generate one standing-order entry" operation is 2-3 separate transactions, not one, and a crash between them (e.g. after create_entry commits but before create_from_template's second commit sets source_type) leaves a real, posted-eligible draft entry with source_type=NULL instead of "standing_order" - silently miscategorized, not lost.
  • 2026-09-12 (measured at 62ba4fc): tests/v2/coverage/test_voila_g3_...py's own module docstring documents a duplicate-CREATE TYPE failure in the SHARED tests/conftest.py test_engine fixture when the full model graph is imported (two enum-class pairs share a Postgres type name). This recipe's own G2 test file runs against that shared fixture unmodified and passes, so the failure is import-order-dependent, not universal - but it is real enough that G3 had to build a private-schema workaround rather than use the shared fixture, and any new DB-backed test added to this domain should check whether it collides before assuming the shared db_session fixture "just works".

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 (== origin/main's tip at measurement time; git merge-base --is-ancestor 62ba4fc origin/main exits 0).

  • alchemy_labs/backend/services/accounting/journal_service.py (495 lines, whole file in scope for this recipe except nothing - every method is covered): create_entry (27-81), get_entry (83-97), list_entries (99-131), update_entry (133-185), delete_entry (187-200), post_entry (202-209), void_entry (211-218), bulk_post (220-230), list_templates (234-242), create_from_template (244-295), create_template (299-332), update_template (334-354), delete_template (356-373), get_template (375-393), get_due_templates (395-419), generate_due_entries (421-471), _advance_next_due_date (473-492).
  • alchemy_labs/backend/services/accounting/engine.py (300 lines, whole file): validate_lines (33-61), validate_accounts (63-89, the bug's location), validate_fiscal_year (91-112), assign_entry_number (116-136), post_entry (140-178), void_entry (180-245), get_account_balance (249-297, out of this recipe's scope, cited for context - it is what a balance display would call, this recipe never calls it itself).
  • alchemy_labs/backend/api/routers/accounting_router.py: imports (1-37); list_journal_entries through void_journal_entry (530-646); list_templates through upload_base_document (915-998, upload_base_document itself out of scope); detect_patterns_endpoint/accept_pattern (1003-1052, out of scope, cited to draw the line).
  • alchemy_labs/backend/models/accounting_models.py: JournalEntryStatus/JournalTemplateFrequency enums (31-35, 76-82); JournalEntry (158-193); JournalEntryLine (196-225); AccountingJournalTemplate (228-256); AccountingAuditLog (516-536, cited for the audit-row shape log_action writes).
  • alchemy_labs/backend/schemas/accounting_schemas.py: JournalEntryLineBase/Create/Response (116-159); JournalEntryBase/Create/Update/Response (166-217); JournalEntryVoidRequest/BulkPostRequest/BulkPostResponse (224-236); JournalTemplateBase/Create/Update/Response (242-286); GenerateDueRequest/Response (288-296).
  • alchemy_labs/backend/services/accounting/audit_service.py: log_action (19-45), get_audit_trail (48-66).
  • alchemy_labs/backend/services/scheduled_jobs.py: job registration (305-312); run_accounting_standing_orders (1726-1793).
  • alchemy_labs/backend/migrations/versions/20260214_add_accounting_tables.py (journal tables: 85-158); 20260223_add_standing_order_fields.py (whole file, 58 lines).
  • Tests as listed under Tests: both coverage files in full, tests/v2/coverage/.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the str-vs-UUID account-id bug makes recurring-template generation non-functional for any template with lines (Gotchas, first entry); business-rule ValueErrors surface as opaque 500s instead of 400s on post/void/update (Gotchas, second entry); multiple un-batched commits per logical operation on the template-generation paths (Gotchas, third entry); no route-level HTTP test exists for any endpoint in this recipe (Tests); no concurrency test proves the gap-free entry-number lock under real contention (Tests).

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc with postgres-test (docker-compose.test.yml, port 5434) up. Expected observations are exact, as measured 2026-09-12.

  1. TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db .venv/bin/python -m pytest tests/v2/coverage/test_voila_g2_journal_service_crud_lifecycle_draft_only_edit_delete_bulk_post_service_level_db_.py -q --no-cov -o addopts="" -p no:cacheprovider prints 21 passed.
  2. TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db .venv/bin/python -m pytest tests/v2/coverage/test_voila_g3_journal_templates_standing_orders_dauerauftr_ge_service_level_db_.py -q --no-cov -o addopts="" -p no:cacheprovider prints 28 passed, 5 xfailed.
  3. grep -c "^def test_" tests/v2/coverage/test_voila_g2_journal_service_crud_lifecycle_draft_only_edit_delete_bulk_post_service_level_db_.py prints 21; the same command against the G3 file prints 34 (one of which, test_user, is a @pytest.fixture and not collected - pytest tests/v2/coverage/test_voila_g3_...py --collect-only -q ends with 33 tests collected).
  4. grep -n " def validate_accounts" services/accounting/engine.py prints line 64, and grep -n "found_ids = {a.id for a in accounts}" services/accounting/engine.py prints line 76 - confirms the exact site of the str-vs-UUID comparison behind the xfails in check 2.
  5. grep -n "except ValueError" api/routers/accounting_router.py prints nothing (no match, exit code 1) - confirms no router handler in this file catches the ValueErrors post_entry/void_entry/update_entry's line-replacement path can raise, i.e. they fall through to the generic 500 handler as described in Gotchas.
  6. grep -n "^def post_entry\|^def void_entry" services/accounting/engine.py prints 140 and 180; grep -n "entry.entry_number = self.assign_entry_number" services/accounting/engine.py prints 168 - the one and only site that writes entry_number.
  7. git merge-base --is-ancestor 62ba4fc41009b454e1c033e5036c7e8577db2061 origin/main; echo $? prints 0, confirming maturity: prod.

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.