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 |
|
| 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:
| Route | Status codes | Notes |
|---|---|---|
GET /journal-entries (530-544) | 200 | filters: 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) | 201 | body JournalTemplateCreate |
PATCH /templates/{id} (932-936) | 200, 404 | exclude_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_numberis assigned exactly once, under the fiscal year's row lock, and never reassigned or reused - nothing in this fileUPDATEsentry_numberoutsideassign_entry_number(grep -n "entry_number ="finds it only inengine.py168).- A posted or void entry is never edited or hard-deleted by this service -
update_entryanddelete_entryboth gate onstatus == draftbefore 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 onlystatus=void/voided_*/void_reason; the reversal is a distinct row linked bysource_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_idand every query isWHERE ... workspace_id == workspace_id(get_entry87-93,get_template384-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_actionis called fromcreate_entry,update_entry,post_entry,void_entry,create_template,update_template,delete_template- not fromdelete_entry's caller but fromdelete_entryitself, 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.py63-89, xfail evidence intest_voila_g3_...py54-81, 483-757):generate_due_entriesandcreate_from_templateare broken for every template that has realtemplate_lines.template_linesis aJSONcolumn; everyaccount_idinside it round-trips as a Pythonstr.engine.validate_accountsbuildsfound_ids = {a.id for a in accounts}from the ORM (realUUIDobjects) and then checksaid not in found_idsfor eachaidin the caller-supplied list -str(existing_uuid) not in {existing_uuid}is alwaysTrue, 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_...pyprints28 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 isstrict=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-workspacetry/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 inengine.validate_accounts(comparestr(a.id)tostr(aid), or coerceaidtoUUIDbefore the membership test), not in this recipe. - 2026-09-12 (measured at 62ba4fc,
app_fastapi.py795-813,journal_service.py/engine.pythroughout): business-rule rejections that originate as a plainValueError(wrong status on post/void, unbalanced replacement lines onupdate_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 intry/except ValueError, so FastAPI's genericExceptionhandler returns a 500 with a deliberately opaque body.JournalEntryCreate's own Pydantic validator (accounting_schemas.py177-186) happens to catch the unbalanced-lines case for plainPOST /journal-entriesas a clean 422, but that protection does not extend toPATCH /journal-entries/{id}(whoselinesfield has no matching validator onJournalEntryUpdate), nor toPOST .../post,POST .../void, or either template-generation route. A caller integrating against this API should not read a 500 from/postor/voidas "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.py220-230, 244-295, 421-471): three call sites layer a seconddb.commit()on top of a method that already committed internally (bulk_postcallingpost_entryin a loop;create_from_templatecommitting again aftercreate_entry's own commit to persistsource_type/receipt_file_path/last_generated;generate_due_entriescallingcreate_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. aftercreate_entrycommits but beforecreate_from_template's second commit setssource_type) leaves a real, posted-eligible draft entry withsource_type=NULLinstead 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 TYPEfailure in the SHAREDtests/conftest.pytest_enginefixture 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 shareddb_sessionfixture "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_entriesthroughvoid_journal_entry(530-646);list_templatesthroughupload_base_document(915-998,upload_base_documentitself 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/JournalTemplateFrequencyenums (31-35, 76-82);JournalEntry(158-193);JournalEntryLine(196-225);AccountingJournalTemplate(228-256);AccountingAuditLog(516-536, cited for the audit-row shapelog_actionwrites).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.
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:cacheproviderprints21 passed.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:cacheproviderprints28 passed, 5 xfailed.grep -c "^def test_" tests/v2/coverage/test_voila_g2_journal_service_crud_lifecycle_draft_only_edit_delete_bulk_post_service_level_db_.pyprints21; the same command against the G3 file prints34(one of which,test_user, is a@pytest.fixtureand not collected -pytest tests/v2/coverage/test_voila_g3_...py --collect-only -qends with33 tests collected).grep -n " def validate_accounts" services/accounting/engine.pyprints line64, andgrep -n "found_ids = {a.id for a in accounts}" services/accounting/engine.pyprints line76- confirms the exact site of the str-vs-UUID comparison behind the xfails in check 2.grep -n "except ValueError" api/routers/accounting_router.pyprints nothing (no match, exit code 1) - confirms no router handler in this file catches theValueErrorspost_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.grep -n "^def post_entry\|^def void_entry" services/accounting/engine.pyprints140and180;grep -n "entry.entry_number = self.assign_entry_number" services/accounting/engine.pyprints168- the one and only site that writesentry_number.git merge-base --is-ancestor 62ba4fc41009b454e1c033e5036c7e8577db2061 origin/main; echo $?prints0, confirmingmaturity: 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.