Customer invoicing with Swiss QR-bill PDFs
bookkeeping/customer-invoicing-and-qr-bill-pdf
| Kind | backend |
|---|---|
| Domain | bookkeeping |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, fpdf2, qrcode-8.2, pillow-11.3, alembic |
| Depends on | _shared/fastapi-project-layout, _shared/alembic-migrations, _shared/workspace-scoping, _shared/error-envelope, _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 creates a draft invoice, sends it, gets paid, or cancels it, and every send produces exactly one balanced, posted journal entry (invoice_service.py 422 to 499) while every invoice can render as a PDF carrying a validated Swiss QR-bill payment slip (invoice_pdf_service.py, qr_bill_service.py). The invariant that makes it trustworthy is numeric, not procedural: send_invoice and mark_paid each build their journal lines so that sum(debit) == sum(credit) before calling engine.post_entry, which re-validates that balance and refuses to post an unbalanced entry (engine.py 158 to 162), and invoice numbers are assigned under a SELECT ... FOR UPDATE lock on the workspace row so two concurrent creates cannot mint the same RE-YYYY-NNNNN (invoice_service.py 33 to 53). What this recipe does NOT make trustworthy across tenants is the QR-bill's own payment details - see Gotchas, this is the recipe's most load-bearing finding.
When to use
Read it before touching create_invoice, update_invoice, send_invoice, mark_paid, cancel_invoice, _next_invoice_number, _create_journal_entry, or the QR-bill validators (validate_iban, validate_qr_reference, validate_scor_reference, generate_spc_data); before changing the auto-journal account mapping (ACCOUNT_DEBITOREN, ACCOUNT_MWST_SCHULD, ACCOUNT_ERTRAG); and when someone asks "why is this invoice number wrong", "why didn't sending create a journal entry", "is the QR-bill on this PDF valid", or "why does the QR code point at the wrong bank account". Trigger phrases: "Rechnung", "Einzahlungsschein", "QR-Reference", "SCOR reference", "QR-IBAN", "Debitoren", "Forderung", "SPC", "gap-free invoice number".
Not this recipe: POST /qr-bill/validate and GET /vat-rates are the same router file and are cited here because they are load-bearing for QR-bill correctness, but VAT report generation/submission, expense booking, capital/dividend calculation, and the balance-sheet/trial-balance/account-ledger reports (accounting_reports_router.py, accounting_expenses_router.py) are separate recipes covering the same AccountingAccount/JournalEntry tables from the other side; the chart-of-accounts seeding this recipe's send_invoice/mark_paid depend on (chart_of_accounts_service.seed_default_chart, ~70 default Swiss KMU accounts) is a neighbouring recipe, this one only calls _find_account against whatever is already seeded; actual email transport (services/email_service, _send_via_resend_with_attachment) is consumed here (invoice_service.py 380 to 401) but not owned - email_service is a neighbouring, not-this-wave module; CRM lead lookup for recipient email resolution (_resolve_recipient_email, models/crm_model.Lead) is read for context only, the CRM model itself is out of scope; bank reconciliation (accounting_bank_router.py) that later matches a bank statement line against mark_paid's journal entry is a separate recipe.
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 (14 in the test harness per docker-compose.test.yml, matching S9/_shared/test-layout). PDF generation: fpdf2>=2.8.4 (requirements.txt 146) and qrcode==8.2 (95) plus Pillow, pinned twice in requirements.txt - pillow==11.3.0 (70) and a second, looser Pillow>=10.0.0 (94) - both resolve to the same installed 11.3.0 in this environment but the duplicate pin is itself a Gotcha, not intentional redundancy documented anywhere. Foundations assumed: S4 (fastapi-project-layout) for mount_router (the router is mounted at /api/accounting, app_fastapi.py 916); S8 (alembic-migrations) for the chained migration under Deploy & ops; S6 (workspace-scoping) for WorkspaceID/ActiveUser and _get_workspace_id's access-checked resolution (api/dependencies.py 680 to 779); S7 (error-envelope) for how the HTTPException(status_code=..., detail=...) calls in this file surface through the app's global error-shape middleware - this recipe's own code never builds an envelope itself, it just raises; S9 (test-layout) for the db_session/test_engine fixtures in tests/conftest.py and the tests/v2/coverage/conftest.py full-model-graph-import pattern this recipe's own tests rely on (see Tests).
No dedicated environment variable gates this recipe. _send_invoice_email's import of services.email_service is inside a try/except (invoice_service.py 296 to 405) so a missing or misconfigured mail transport degrades send_invoice to "sent, no email" rather than failing the send - send_invoice itself never raises for an email failure, _send_invoice_email catches everything and returns False (403 to 405), logged, not surfaced to the caller. PDF generation failure inside the email path is the one exception that IS re-raised past that point (except Exception as pdf_err: ... raise Exception(...), 395 to 397) then caught one layer up by the same outer except, so a broken PDF still only degrades the email, it does not fail send_invoice's journal entry or status change, which have already been committed by that point (486 to 490 run before the email block at 492 to 497).
Data model
Two tables, both under workspace_id (models/accounting_models.py 264 to 344; migrated in 20260214_add_accounting_tables.py):
accounting_invoices (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id),
invoice_number varchar(50) NOT NULL, -- RE-YYYY-NNNNN / ER-YYYY-NNNNN
invoice_type invoice_type_enum NOT NULL, -- outgoing | incoming
status invoice_status_enum NOT NULL DEFAULT 'draft', -- draft|sent|paid|overdue|cancelled|credited
issue_date date NOT NULL,
due_date date NOT NULL,
currency varchar(3) DEFAULT 'CHF',
subtotal numeric(15,2) NOT NULL,
vat_amount numeric(15,2) DEFAULT 0.00,
total_amount numeric(15,2) NOT NULL,
paid_amount numeric(15,2) DEFAULT 0.00,
issuer_name varchar(255) NOT NULL,
issuer_address_line1/2, issuer_zip, issuer_city, issuer_country, issuer_vat_number,
recipient_name varchar(255) NOT NULL,
recipient_address_line1/2, recipient_zip, recipient_city, recipient_country,
qr_iban varchar(34), -- entered by the caller, NOT validated on write (see Gotchas)
qr_reference varchar(27),
scor_reference varchar(25),
payment_message varchar(140),
journal_entry_id uuid REFERENCES accounting_journal_entries(id), -- set only by send_invoice
contact_id uuid, -- CRM lead id, no FK
notes text,
created_by uuid NOT NULL REFERENCES users(id),
created_at, updated_at, deleted_at timestamptz,
UNIQUE (workspace_id, invoice_number),
INDEX (workspace_id, status), INDEX (workspace_id, due_date)
);
accounting_invoice_lines (
id uuid PRIMARY KEY,
invoice_id uuid NOT NULL REFERENCES accounting_invoices(id) ON DELETE CASCADE,
line_number integer NOT NULL,
description varchar(500) NOT NULL,
quantity numeric(10,3) DEFAULT 1.000,
unit varchar(20),
unit_price numeric(15,2) NOT NULL,
amount numeric(15,2) NOT NULL,
vat_rate numeric(5,2),
vat_amount numeric(15,2) DEFAULT 0.00,
account_id uuid REFERENCES accounting_accounts(id), -- revenue account for THIS line; NULL falls back to 3200 on send
created_at, updated_at timestamptz
);
send_invoice writes into accounting_journal_entries/accounting_journal_entry_lines (owned by the neighbouring journal-entry recipe, not redefined here); mark_paid writes a second such entry with source_type="invoice_payment". What is NOT stored anywhere in this recipe: the rendered PDF bytes (generated on demand by GET /invoices/{id}/pdf and again inside _send_invoice_email, never persisted, never cached - two sends of the same invoice or one send plus one download regenerate the QR code and PDF from scratch each time); the SPC QR payload string itself; the recipient's resolved email address (looked up live from contact_id at send time, _resolve_recipient_email, 407 to 418, never copied onto the invoice row); any validation result from qr_bill_service (iban_valid, qr_reference_valid, etc. exist only in the POST /qr-bill/validate response, nothing on accounting_invoices records whether its own qr_iban/qr_reference ever passed that check).
API surface
| Route | Auth | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /api/accounting/invoices (accounting_invoices_router.py 23 to 35) | ActiveUser | WorkspaceID | 200 | filters invoice_type/invoice_status, paginated limit/offset |
POST /api/accounting/invoices (38 to 43) | ActiveUser | WorkspaceID | 201, 422 (InvoiceCreate.lines requires min_length=1, measured: test_create_invoice_validation_error_missing_lines) | creates draft, assigns number |
GET /api/accounting/invoices/{id} (46 to 48) | ActiveUser | WorkspaceID | 200, 404 (not found, cross-workspace, or soft-deleted - all three collapse to 404, get_invoice 129 to 141) | |
PATCH /api/accounting/invoices/{id} (51 to 57) | ActiveUser | WorkspaceID | 200, 400 (non-draft) | whitelist of 21 fields (allowed_fields, 174 to 180); invoice_number and status are NOT in the whitelist and silently ignored if sent (measured: test_update_invoice_ignores_non_whitelisted_fields) |
POST /api/accounting/invoices/{id}/send (60 to 65) | ActiveUser | WorkspaceID | 200, 400 (non-draft), 400 (no 1100/3200/2200 account in workspace, _find_account), 400 (no open fiscal year covering issue_date, _find_fiscal_year) | optional recipient_email query param overrides CRM lookup |
POST /api/accounting/invoices/{id}/mark-paid (68 to 73) | ActiveUser | WorkspaceID | 200, 400 (status not sent/overdue) | body InvoiceMarkPaidRequest: paid_amount, payment_date, bank_account_number all optional |
GET /api/accounting/invoices/{id}/pdf (76 to 92) | ActiveUser | WorkspaceID | 200 application/pdf inline, 404 (via get_invoice), 422 (PDF generation raised, caught and re-raised as 422 with str(e), 89 to 92) | regenerates the PDF on every call, nothing cached |
POST /api/accounting/invoices/{id}/cancel (95 to 97) | ActiveUser | WorkspaceID | 200, 400 (already cancelled/credited) | no journal reversal - see Gotchas |
POST /api/accounting/qr-bill/validate (102 to 110) | ActiveUser | none (pure function, no workspace read) | 200 always (validators return booleans, never raise) | QrValidationRequest → QrValidationResponse; advisory only, nothing calls this from create_invoice/update_invoice/send_invoice |
GET /api/accounting/vat-rates (113 to 116) | ActiveUser | none | 200 | delegates to vat_service.get_rates(), out of this recipe's scope |
Application logic
create_invoice (55 to 127): _next_invoice_number locks the workspaces row FOR UPDATE, counts existing rows matching {prefix}-{year}-% (prefix RE outgoing / ER incoming), returns count+1 zero-padded to 5 digits (33 to 53) - this is a COUNT, not a sequence, so a row deleted mid-year would be re-issued, but nothing in this recipe deletes invoice rows, only soft-deletes (deleted_at), and the count query does not filter deleted_at, so a soft-deleted invoice still occupies its number. Sums amount/vat_amount across lines_data into subtotal/vat_amount/total_amount (62 to 80). Inserts the invoice, flushes to get an id, inserts each line with line_number = i+1 (106 to 120), audit-logs, commits, refreshes.
update_invoice (166 to 218): rejects non-draft (171 to 172, 400). Applies only whitelisted fields whose value is not None (174 to 183). If lines_data is not None, deletes ALL existing lines and re-inserts from scratch (186 to 208) and recomputes the three totals (210 to 212) - a partial line update is not supported, the caller must resend every line.
send_invoice (422 to 499), in commit order: rejects non-draft (430 to 431, 400). Looks up account 1100 (434, 400 if missing). Builds journal lines: one debit line for the full total_amount against 1100 (440 to 445); one credit line PER DISTINCT revenue account referenced across invoice.lines (448 to 462) - a line with no account_id falls back to account 3200 (451 to 452, 400 if 3200 missing), lines sharing an account are summed into one journal line, not one line per invoice line; one credit line against 2200 for vat_amount ONLY if vat_amount > 0 (465 to 472). Calls _create_journal_entry, which locates the open fiscal year covering issue_date (238 to 252, 400 if none), inserts the JournalEntry + lines as draft, then calls engine.post_entry(..., auto_commit=False) which re-validates the balance, assigns a gap-free entry_number (per workspace + fiscal year, itself lock-protected, engine.py 117 to 136), and marks the entry posted (immutable from here). Sets invoice.status = sent, invoice.journal_entry_id = je.id, commits. AFTER that commit (492 to 497), resolves an email (explicit recipient_email param, else _resolve_recipient_email via CRM contact_id) and calls _send_invoice_email, whose own failure (including a PDF generation failure) is caught and logged, never raised back to the caller or rolled back against the already-committed send.
mark_paid (501 to 543): rejects any status other than sent/overdue (512 to 513, 400). amount = paid_amount or invoice.total_amount - a caller can mark an invoice paid for less (partial) or, since nothing checks amount <= total_amount - paid_amount, MORE than the outstanding balance, or paid twice with no guard against a second mark_paid call re-crediting 1100 a second time (the state machine only blocks a re-call once status is paid, since paid is not in {sent, overdue} - so this actually IS blocked on a second call, but nothing reconciles a partial-then-full sequence into one correct running balance; invoice.paid_amount is OVERWRITTEN, not accumulated, 536). Debits bank_account_number (default 1020, PostFinance) for amount, credits 1100 for amount (524 to 534), same _create_journal_entry path with source_type="invoice_payment".
cancel_invoice (545 to 557): rejects cancelled/credited (548 to 549, 400). Sets status = cancelled. Nothing else - a sent invoice that already posted a journal entry is cancelled with NO reversing entry and journal_entry_id is left pointing at the still-posted 1100/revenue/2200 entry; the books and the invoice status disagree from that point on (see Gotchas).
Invariants
- A posted journal entry is always balanced: enforced by construction in
send_invoice/mark_paid(debit total == credit total by how the lines are built) and re-checked independently byengine.post_entrybefore it assigns a number (engine.py158 to 162; measured:test_send_invoice_creates_balanced_posted_journal_entry,total_debit == total_credit == Decimal("1081.00")). - An invoice number is unique per workspace and gap-free under the workspace row lock (
_next_invoice_number;uq_accounting_invoices_ws_number; measured:test_next_invoice_number_is_gap_free_increment). Not proven under concurrency: no test in either coverage file spawns two threads against_next_invoice_numberthe way the billing-credits recipe's auto-topup engine proves its own lock (see Tests) - theFOR UPDATElock is real but its correctness under a genuine race is asserted by code reading, not by a concurrency test in this repo. send_invoice/mark_paidonly run against a chart of accounts and fiscal year that already exist in the workspace; both raise 400 rather than silently skipping the journal entry (measured:test_send_invoice_fails_without_chart_of_accounts,test_create_journal_entry_no_open_fiscal_year_raises) - an invoice can NEVER move tosentorpaidstatus without a corresponding posted journal entry, the two are written in the same call and the status change happens after_create_journal_entryreturns (474 to 486), not before.update_invoice's field whitelist keepsinvoice_numberandstatusimmutable through that endpoint (measured:test_update_invoice_ignores_non_whitelisted_fields).- Only
draftinvoices are editable or sendable; onlysent/overdueinvoices can be marked paid;cancelled/creditedinvoices cannot be cancelled again - each gate is a single status check, not a general state-machine table (measured:test_update_invoice_rejects_non_draft,test_send_invoice_rejects_non_draft,test_mark_paid_rejects_draft,test_cancel_invoice_rejects_already_cancelled). - NOT an invariant, despite reading like one: cancelling a
sentinvoice does not reverse its journal entry (Application logic,cancel_invoice) - stated here because its absence is easy to assume covered and is not.
Frontend
The module that renders this, by id:
bookkeeping/ui/bookkeeping-financial-dashboard the workspace-facing invoice list, create/edit form,
send/mark-paid/cancel actions and the PDF download
link this recipe's routes are consumed by; written by
a parallel agent in this same kit, not verified against
from this recipe
Minimum admin/debug surface (no dedicated route exists at this commit): SELECT invoice_number, status, journal_entry_id, total_amount, paid_amount FROM accounting_invoices WHERE workspace_id = :ws ORDER BY issue_date DESC (the list the router's own GET /invoices serves); SELECT ai.invoice_number, ai.status, je.status AS je_status, je.entry_number FROM accounting_invoices ai JOIN accounting_journal_entries je ON je.id = ai.journal_entry_id WHERE ai.status = 'cancelled' (finds a cancelled invoice whose journal entry is still posted, the gap above); the log lines Created invoice %s, Sent invoice %s, JE #%s, Marked invoice %s as paid, JE #%s, Cancelled invoice %s (invoice_service.py 126, 490, 542, 556) and PDF generation failed for invoice %s: %s / QR code generation failed: %s (396, invoice_pdf_service.py 567) for the two failure paths that degrade rather than raise.
Security & tenancy
Auth dependency: ActiveUser (Depends(get_current_active_user), api/dependencies.py 566) on every route in this router including qr-bill/validate and vat-rates; there is no unauthenticated route in this recipe (measured: test_create_invoice_requires_auth, test_vat_rates_requires_auth, test_qr_validate_requires_auth, all assert the response is in the auth-rejected set). Tenancy: WorkspaceID (Depends(_get_workspace_id), 680 to 779) resolves from request.state.workspace_id (pre-validated by WorkspaceMiddleware for JWT requests) or the X-Workspace-ID header re-validated against the caller, never from the request body - workspace_id is never a field a client can set on InvoiceCreate/InvoiceUpdate. Every service method takes workspace_id as an explicit argument and every query filters on it (get_invoice, list_invoices, _find_account, _find_fiscal_year all and_(..., workspace_id == workspace_id)); a cross-workspace read or mutation answers 404, not 403 (measured: test_get_invoice_cross_workspace_raises_404, test_invoice_cross_tenant_404).
Fail direction: closed on the money-moving actions - send_invoice/mark_paid raise 400 rather than proceeding without a resolvable account or fiscal year; cancel_invoice does NOT fail closed on the one thing that would matter (a posted journal entry left standing, above). POST /qr-bill/validate is the one route in this file that fails OPEN in a different sense: it never rejects malformed input, it always returns 200 with booleans, by design (it is a validator, not a gate) - nothing in create_invoice/update_invoice/send_invoice calls it, so an invoice can be created, sent, and rendered to PDF with an qr_iban that would fail validate_iban and the caller only finds out by separately calling /qr-bill/validate or by a human reading the printed QR-bill.
Secrets and PII: none of AccountingInvoice's columns hold a credential; qr_iban is a bank account identifier (not a card number, no PCI scope) but is PII-adjacent for the recipient's bank details when they are the debtor on the QR-bill; nothing in this recipe encrypts it at rest, it is a plain varchar. Attacker-controlled inputs, all behind ActiveUser+WorkspaceID: every field on InvoiceCreate/InvoiceUpdate including qr_iban/qr_reference/scor_reference/payment_message (free text up to 140 chars, embedded verbatim into the SPC payload after only whitespace-stripping via _sanitize, qr_bill_service.py 151 to 152 - no length re-check against the SPC spec's own field limits happens in this file, payment_message is truncated at 140 by the DB column, nothing shorter); recipient_email as a send_invoice query parameter, accepted with no format validation before being handed to the mail transport. Rate limits: none dedicated to this router.
Tests
Measured 2026-09-12 against the scratch Postgres on localhost:5434 (docker-compose.test.yml's postgres-test, already running), from alchemy_labs/backend with .venv:
.venv/bin/python -m pytest tests/accounting/test_qr_bill.py -q --no-cov -o addopts="" -p no:cacheprovider
prints 17 passed. Hermetic: pure functions, no database, no network (validate_iban, validate_qr_reference, generate_scor_reference, etc., all @staticmethod).
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
.venv/bin/python -m pytest tests/v2/coverage/test_voila_g4_invoice_service_numbering_lifecycle_auto_journal_entries_service_level_db_.py \
-q --no-cov -o addopts="" -p no:cacheprovider
printed pytest's own summary line verbatim as 23 passed, 332 warnings, 1 error in 11.85s, naming test_get_invoice_cross_workspace_raises_404 as the one error (ERROR at setup of test_get_invoice_cross_workspace_raises_404, psycopg2.errors.UndefinedTable: relation "leads" does not exist); --collect-only against the same file independently confirms 23 tests collected, so this recipe reports pytest's own count rather than reconciling it against that total. This file only imports models.accounting_models (its own docstring, line 23 to 25), not the full app graph; the second_workspace fixture the failing test depends on needs the CRM leads table, which tests/v2/coverage/conftest.py's own docstring says is only registered when app_fastapi is imported first (its docstring: "A pure-service suite like this one must register the same models itself, otherwise test_engine.create_all would only build a partial schema"). This is a real, dated gap in the test file's own fixture wiring, not a defect in the recipe's application code.
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
.venv/bin/python -m pytest tests/v2/coverage/test_voila_g12_endpoint_layer_invoices_expenses_reports_vat_capital_qr_bill_auth_tenant_lifecycle_via_testclient_.py \
-q --no-cov -o addopts="" -p no:cacheprovider
run immediately after the G4 file above against the same un-reset scratch database printed 9 passed, 30 errors, every error psycopg2.errors.UndefinedTable: relation "users" does not exist. This is exactly the condition the suite's own tests/v2/coverage/conftest.py docstring warns about: "[the model graph] does not always round-trip cleanly between separate pytest invocations, so this suite is intended to be run against a freshly reset public schema (reset the schema, then invoke pytest once)." I did not reset the schema between the two runs (a DROP SCHEMA on the shared scratch database was refused by this session's own sandboxing, not attempted against a real environment) so this number is NOT a finding about the recipe's endpoint coverage; it is a measured reproduction of a documented cross-invocation caveat, recorded here rather than silently re-run to get a cleaner-looking number. The invoice- and QR-bill-specific tests this file contains, read but not independently re-run clean in this session: test_create_invoice_201_draft_with_computed_totals, test_send_invoice_creates_posted_balanced_journal_entry, test_send_invoice_rejects_non_draft_400, test_mark_paid_only_after_send, test_patch_invoice_updates_draft, test_cancel_invoice_sets_cancelled_and_rejects_double, test_invoice_cross_tenant_404, test_get_vat_rates_returns_swiss_mwst_rates, test_qr_validate_valid_iban_and_uid, test_qr_validate_invalid_fields_report_errors, test_qr_validate_requires_auth (11 tests, endpoint-layer coverage for every route in the API surface table above except GET /invoices/{id}/pdf, which no test file exercises - see Gotchas).
No test file in this recipe's reference set renders a PDF and asserts on its bytes, structure, or QR payload; test_qr_bill.py only exercises qr_bill_service's standalone validators, never generate_spc_data, and no test calls invoice_pdf_service.generate at all. PDF rendering is the one piece of this recipe's scope with zero automated test coverage in the measured commit.
Deploy & ops
Config keys: none dedicated to this recipe (contrast the billing-credits recipe's PRIMA_* env vars - invoicing needs no external credential, PDF generation is local CPU/font/image work and QR-bill data never leaves the process). Migration: 20260214_add_accounting_tables.py (revision 20260214_accounting_tables, down-revision 20260206_webhook_events) creates accounting_invoices and accounting_invoice_lines among the accounting module's other tables in one migration (lines 160 to 231), applied once, no follow-up revision touches these two tables through the measured commit. Router mount: unconditional, mount_router(app, accounting_invoices_router.router, prefix="/api/accounting", ...) (app_fastapi.py 916), no feature flag, alongside eight other accounting routers under the same prefix. No scheduler job, no background worker - every action in this recipe is synchronous inside its own HTTP request.
Argus-style check: POST /api/accounting/invoices/{id}/send on a freshly created draft in a workspace with the default chart of accounts and an open fiscal year returns 200 with journal_entry_id set, and the referenced JournalEntry.status == 'posted' with a non-null entry_number (exactly test_send_invoice_creates_posted_balanced_journal_entry's assertions). Rollback: the migration's downgrade() drops accounting_invoices (452) - a rollback of this migration is destructive and only sane before any invoice exists in the target environment; there is no soft-migration path for an in-use deployment.
Gotchas
- 2026-09-12 (measured,
invoice_pdf_service.py20 to 29, 118, 374, 448, 499 to 502): the issuer identity baked into the PDF and the QR-bill is a set of module-level constants, not read from the invoice's ownissuer_address_line1/issuer_zip/issuer_citycolumns.invoice.issuer_nameIS used (with a constant fallback), but the QR-bill's creditor address, zip and city, the Empfangsschein/Zahlteil "Konto / Zahlbar an" block, and the SPC payload'screditor_address1/creditor_zip/creditor_cityall read fixed constants, never the invoice row. Separately,iban = invoice.qr_iban or DEFAULT_IBAN(118): if a caller creates or sends an invoice without settingqr_iban, the rendered QR-bill silently falls back to the operator's own hardcoded IBAN constant rather than erroring - a multi-tenant deployment of this exact code would put every under-configured tenant's QR-bill payment slip against the SAME bank account. This is the reasonreusable: extractrather thanyes: the numbering/journal/validation logic generalizes cleanly, the PDF/QR-bill renderer does not, without first making issuer identity and IBAN fallback workspace-configurable (or removing the fallback and 400ing instead). - 2026-09-12 (measured,
git logoninvoice_service.py):8ac9d2a "Fix fallback revenue account: 3200 not 3000"- the current fallback account for a line with noaccount_idis3200(ACCOUNT_ERTRAG), and this was a real, shipped, fixed-in-prod bug, not a hypothetical; a rebuild should not assume3000from general Swiss KMU chart conventions. - 2026-09-12 (measured, same log):
15b66a8 "Fail hard if invoice PDF generation fails - no silent fallback"- a deliberate choice thatGET /invoices/{id}/pdfre-raises as 422 rather than returning a partial or placeholder PDF (accounting_invoices_router.py89 to 92); contrast_send_invoice_email's own PDF failure, which IS silently swallowed intoemail_sent: false-equivalent behavior (Stack assumptions) - the same failure mode is fail-closed on one call path and fail-open on the other, by design, not by oversight (the two commits are three apart in history). - 2026-09-12 (measured,
qr_bill_service.py69 to 78, 98 to 110):generate_qr_reference/generate_scor_referenceexist and are unit-tested (test_qr_bill.py'sTestQrReference/TestScorReference) but are called from NOWHERE ininvoice_service.pyor the router - a caller must generate a valid reference themselves and pass it onInvoiceCreate/InvoiceUpdate, this recipe never mints one on the server side despite owning the generator. - 2026-09-12 (measured,
requirements.txt70 and 94): Pillow is pinned twice,pillow==11.3.0andPillow>=10.0.0; both resolve to the same package in this environment (pip is case-insensitive on the name) but the duplication itself is unexplained in the file and worth collapsing in a rebuild rather than copying forward. - 2026-09-12 (measured, Tests): PDF rendering (
invoice_pdf_service.generate, the SPC payload it embeds) has no automated test in either test file cited by this recipe. A rebuild that changes_draw_qr_bill,_draw_qr_code, orgenerate_spc_data's field ordering has nothing in this repo's test suite that would catch a malformed SPC string.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/accounting/invoice_service.py(561 lines):ACCOUNT_DEBITOREN/ACCOUNT_MWST_SCHULD/ACCOUNT_ERTRAGconstants (26 to 28);_next_invoice_number(33 to 53);create_invoice(55 to 127);get_invoice(129 to 141);list_invoices(143 to 164);update_invoice(166 to 218);_find_account(222 to 236);_find_fiscal_year(238 to 252);_create_journal_entry(254 to 292);_send_invoice_email(296 to 405);_resolve_recipient_email(407 to 418);send_invoice(422 to 499);mark_paid(501 to 543);cancel_invoice(545 to 557).alchemy_labs/backend/services/accounting/invoice_pdf_service.py(576 lines): module constants includingDEFAULT_IBAN,COMPANY_*,CONTACT_*(20 to 40);generate(72 to 325);_draw_qr_bill(327 to 458);_corner_marks(460 to 471);_draw_qr_code(473 to 576, callsqr_bill_service.generate_spc_dataat 494 to 512).alchemy_labs/backend/services/accounting/qr_bill_service.py(231 lines, whole file in scope):validate_iban/is_qr_iban(26 to 52);validate_qr_reference/generate_qr_reference(56 to 78);validate_scor_reference/generate_scor_reference(82 to 110);validate_vat_number(114 to 127);generate_spc_data(131 to 191);validate_all(193 to 228).alchemy_labs/backend/api/routers/accounting_invoices_router.py(116 lines, whole file in scope).alchemy_labs/backend/services/accounting/engine.py:assign_entry_number(117 to 136);post_entry(140 to ~180, balance re-check at 158 to 162).alchemy_labs/backend/schemas/accounting_schemas.py:InvoiceLineBase/InvoiceLineCreate/InvoiceLineResponse(338 to 358);InvoiceBase/InvoiceCreate/InvoiceUpdate/InvoiceResponse(365 to 440);InvoiceMarkPaidRequest(443 to 446);QrValidationRequest/QrValidationResponse(684 to 697).alchemy_labs/backend/models/accounting_models.py:InvoiceType/InvoiceStatusenums (38 to 51);AccountingInvoice(264 to 321);AccountingInvoiceLine(324 to 344);AccountingFiscalYear(136 to 155);JournalEntry/JournalEntryLine(158 to ~220, cited for the tablessend_invoice/mark_paidwrite into).alchemy_labs/backend/app_fastapi.py: router mount (916).alchemy_labs/backend/migrations/versions/20260214_add_accounting_tables.py:accounting_invoices/accounting_invoice_linestable creation (160 to 231), indexes (205 to 207).- Tests as listed under Tests:
alchemy_labs/backend/tests/accounting/test_qr_bill.py(96 lines);alchemy_labs/backend/tests/v2/coverage/test_voila_g4_invoice_service_numbering_lifecycle_auto_journal_entries_service_level_db_.py(549 lines) and its own docstring (1 to 25);alchemy_labs/backend/tests/v2/coverage/test_voila_g12_endpoint_layer_invoices_expenses_reports_vat_capital_qr_bill_auth_tenant_lifecycle_via_testclient_.py(748 lines, invoice/QR sections at 268 to 424) and its own docstring (1 to 41);alchemy_labs/backend/tests/v2/coverage/conftest.pyfor the full-model-graph-import caveat (1 to 26).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: issuer address and bank IBAN are hardcoded rather than workspace-configurable (the recipe's central gap, above); cancel_invoice never reverses a posted journal entry; mark_paid's paid_amount overwrites rather than accumulates across partial payments; POST /qr-bill/validate is never called from the write paths that would benefit from it; PDF/QR rendering has zero test coverage; two conflicting Pillow pins.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe) with the scratch Postgres from docker-compose.test.yml reachable at localhost:5434.
.venv/bin/python -m pytest tests/accounting/test_qr_bill.py -q --no-cov -o addopts="" -p no:cacheproviderprints17 passed.TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db .venv/bin/python -m pytest tests/v2/coverage/test_voila_g4_invoice_service_numbering_lifecycle_auto_journal_entries_service_level_db_.py -q --no-cov -o addopts="" -p no:cacheproviderprints23 passed, 1 error, the error attest_get_invoice_cross_workspace_raises_404(see Tests for why);--collect-onlyon the same file prints23 tests collected.- On a freshly reset scratch schema (run this file's own test session first, alone):
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db .venv/bin/python -m pytest tests/v2/coverage/test_voila_g12_endpoint_layer_invoices_expenses_reports_vat_capital_qr_bill_auth_tenant_lifecycle_via_testclient_.py -q --no-cov -o addopts="" -p no:cacheprovideris expected to print all tests passing per the suite's own docstring guarantee; this session measured only the un-reset-schema condition (9 passed, 30 errors, allrelation "users" does not exist) and that number should NOT be quoted as this recipe's endpoint coverage without re-running against a clean schema. grep -n "^ACCOUNT_ERTRAG" services/accounting/invoice_service.pyshows= "3200"(line 28), confirming the fixed fallback revenue account.grep -n "DEFAULT_IBAN = " services/accounting/invoice_pdf_service.pyshows a literal IBAN string (line 21), confirming the hardcoded fallback described in Gotchas; do not copy the value into a rebuild without first deciding how a real multi-tenant deployment supplies its own.grep -c "def test_" tests/accounting/test_qr_bill.pyprints17(indented inside classes,^def test_alone would undercount);grep -c "def test_" tests/v2/coverage/test_voila_g4_invoice_service_numbering_lifecycle_auto_journal_entries_service_level_db_.pyprints23, matching that file's own--collect-onlycount.git log --oneline -- services/accounting/invoice_service.py | wc -lprints9, including the two commits named in Gotchas (8ac9d2a,15b66a8), confirming this file has shipped multiple dated fixes onmain, not just an initial commit.
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.