Bank statement import and transaction reconciliation
bookkeeping/bank-statement-import-and-reconciliation
| Kind | backend |
|---|---|
| Domain | bookkeeping |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, defusedxml-0.7, pypdf, pdfplumber, alembic-1.16, pydantic-2.12 |
| Depends on | _shared/fastapi-project-layout, _shared/alembic-migrations, _shared/workspace-scoping, _shared/auth-dependency-pattern, _shared/test-layout |
| Frontends | bookkeeping/ui/bookkeeping-financial-dashboard |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
A tenant uploads one file (a camt.053 XML export, a CSV export, or a PDF statement from any of the major Swiss retail banks) and gets back, in one request, every transaction on it already split into matched, suggested and unmatched, with the matched ones linked to the invoice or journal entry that already accounts for them, so the books close without the tenant re-typing anything a bank already told them. The invariant that makes this trustworthy is that a transaction is never counted twice: every parsed row gets a dedup_key unique per workspace (uq_bank_transactions_ws_dedup, models/accounting_models.py 646), so a re-uploaded statement, or a statement whose date range overlaps a prior one, silently skips the rows already on file rather than duplicating them (import_statement, bank_reconciliation_service.py 1109 to 1132; proven live by test_import_statement_dedup_skips_duplicate_reimport).
When to use
Read it before touching _parse_camt053/_parse_camt_entry, _parse_csv_bank_statement, _parse_pdf_bank_statement and its PDF helpers, _auto_match, import_statement, confirm_transaction, or book_transaction; and when someone asks "why did this statement come back with 0 matches", "why is this PostFinance PDF garbled", "why did re-importing the same file create duplicates" (it shouldn't, see Purpose), or "why is this transaction's amount CHF instead of EUR". Trigger phrases: "bank reconciliation", "camt.053", "camt053", "Kontoauszug import", "bank statement upload", "dedup_key", "QR-reference match", "SCOR reference", "amount_date match", "Jaccard text match", "auto-match".
Not this recipe: the Mistral-backed account-suggestion fallback for rows the heuristics leave unmatched (_ai_suggest/_call_mistral_for_account, bank_reconciliation_service.py 993 to 1061, called from import_statement at line 1169 wrapped in its own try/except so a Mistral outage never blocks the import); this wave scopes only the deterministic parse-and-match-and-book path, the AI seam is out of scope by the task that produced this recipe; ChartOfAccountsService.seed_default_chart (services/accounting/chart_of_accounts.py), the Swiss-KMU chart-of-accounts seeding that book_transaction depends on for a 102x bank account to exist at all, a neighbouring recipe, cited here only because test_seed_default_chart_creates_swiss_kmu_accounts proves the dependency; posting or voiding the journal entry book_transaction creates, it is created status=JournalEntryStatus.draft (bank_reconciliation_service.py 1311) and this recipe never transitions it further, that lifecycle belongs to the general journal-entry recipe; invoice creation and the QR-bill/SCOR reference fields on AccountingInvoice that make the QR/SCOR match passes possible, this recipe only reads AccountingInvoice.qr_reference/scor_reference, it does not write them.
Stack assumptions
At 62ba4fc4 (backend/requirements.txt): fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), psycopg2-binary==2.9.10, alembic==1.16.5, pydantic==2.12.0, defusedxml==0.7.1 (used instead of the stdlib xml.etree.ElementTree specifically to close XML-entity-expansion attacks on an attacker-uploaded file, bank_reconciliation_service.py 17), pypdf>=3.9.0 with pdfplumber>=0.11.0 as a fallback parser. Foundations assumed: _shared/fastapi-project-layout for the app and mount_router; _shared/alembic-migrations for the merged migration under Data model; _shared/workspace-scoping and _shared/auth-dependency-pattern for WorkspaceID/ActiveUser/DatabaseSession; _shared/test-layout for the tests/v2/coverage/ convention both test files live under.
No bank-reconciliation-specific environment variable exists (grep -n "BANK" backend/core/config.py returns nothing at this commit); the only tunables are in-code constants: MAX_UPLOAD_SIZE = 10 * 1024 * 1024 (accounting_bank_router.py 21) and AI_SUGGEST_MAX = 30 (bank_reconciliation_service.py 991, the out-of-scope AI phase's own cap, kept here only because it explains why import_statement never blocks on it). Fail behaviour: the router's own try/except is the only boundary that matters for this recipe, ValueError from any parser or from get_statement/book_transaction becomes 400 (upload, book) or 404 (get, confirm); any other exception is logged with exc_info=True and answered 500 with no detail leaked (accounting_bank_router.py 49 to 53). The service methods themselves do not catch broadly the way the billing-credits engine's charge_if_due does; a parser exception during import_statement (say a malformed CAMT namespace) propagates straight up to the router uncommitted; nothing here promises "never raises".
Data model
Two tables, one row per uploaded file and one row per parsed transaction (accounting_bank_statements / accounting_bank_transactions, created by migration 20260226_bank_reconciliation, models/accounting_models.py 583 to 651):
accounting_bank_statements (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id),
statement_id varchar(100), -- from CAMT <Stmt><Id>; blank for CSV/PDF
iban varchar(34),
currency varchar(3) DEFAULT 'CHF',
period_start date,
period_end date, -- from the CAMT balance dates, or derived from the min/max transaction date
opening_balance numeric(15,2),
closing_balance numeric(15,2),
file_format varchar(10) NOT NULL, -- 'camt053' | 'csv' | 'pdf'
original_filename varchar(500) NOT NULL,
transaction_count integer DEFAULT 0,
matched_count integer DEFAULT 0,
created_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz DEFAULT now()
);
CREATE INDEX ix_bank_statements_ws ON accounting_bank_statements (workspace_id);
CREATE INDEX ix_bank_statements_ws_iban ON accounting_bank_statements (workspace_id, iban);
CREATE INDEX ix_bank_statements_ws_period ON accounting_bank_statements (workspace_id, period_start, period_end);
accounting_bank_transactions (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id),
statement_id uuid NOT NULL REFERENCES accounting_bank_statements(id) ON DELETE CASCADE,
bank_ref varchar(255), -- AcctSvcrRef; blank for CSV/PDF rows
booking_date date NOT NULL,
value_date date,
amount numeric(15,2) NOT NULL, -- ALWAYS POSITIVE, sign lives in `direction`
currency varchar(3) DEFAULT 'CHF',
direction banktransactiondirection NOT NULL, -- 'credit' | 'debit'
counterparty_name varchar(500),
counterparty_iban varchar(34),
qr_reference varchar(27),
scor_reference varchar(25),
unstructured_ref text,
additional_info text,
match_status banktransactionmatchstatus NOT NULL DEFAULT 'unmatched', -- unmatched|matched|suggested|ignored|booked
match_method varchar(20), -- qr_ref|scor_ref|amount_date|text|ai|manual
match_confidence numeric(3,2), -- 0.00-1.00
matched_journal_entry_id uuid REFERENCES accounting_journal_entries(id),
matched_invoice_id uuid REFERENCES accounting_invoices(id),
suggested_account varchar(10), -- 4-digit account number
dedup_key varchar(255) NOT NULL
);
UNIQUE (workspace_id, dedup_key) -- uq_bank_transactions_ws_dedup, the whole duplicate-import guarantee
CREATE INDEX ix_bank_transactions_ws_status ON accounting_bank_transactions (workspace_id, match_status);
CREATE INDEX ix_bank_transactions_ws_date ON accounting_bank_transactions (workspace_id, booking_date);
CREATE INDEX ix_bank_transactions_stmt ON accounting_bank_transactions (statement_id);
CREATE INDEX ix_bank_transactions_ws_qrref ON accounting_bank_transactions (workspace_id, qr_reference);
There is no CHECK constraint on this pair beyond the two enums and the unique dedup constraint (no cross-column invariant like the auto-topup engine's pending_shape); amount is unsigned by construction, _parse_camt_entry/_parse_csv_bank_statement/_parse_pdf_bank_statement all call abs() before returning (bank_reconciliation_service.py 142, 319/322/328, 776/792/798/806/812/818), so a caller that reads amount without also reading direction reads a meaningless number.
What is NOT stored: no PAN or card data anywhere in this pair (a bank statement carries counterparty IBANs, never a card number); the original uploaded file bytes are not persisted, only the parsed fields, so a corrected re-parse of a bug in one of the parsers cannot be replayed against the original bytes, only against a fresh re-upload; dedup_key's own fallback (line 212 to 214) is a truncated SHA-256 of booking_date|amount|direction|counterparty_name|unstructured_ref|additional_info|entry_index, not a hash of the raw bank_ref, so it carries no reversible information about the transaction; the currency column on BankTransaction is written "CHF" unconditionally by the CAMT parser regardless of the statement's own parsed currency (see Gotchas), so a non-CHF camt.053 statement's transactions are silently mislabeled at the row level even though the statement header's currency is correct.
API surface
| Route | Auth | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /api/accounting/bank/upload (accounting_bank_router.py 24 to 53) | ActiveUser (JWT/cookie/API-key, api/dependencies.py 187, 401 on missing/invalid credential, 403 if the user is inactive) | WorkspaceID (_get_workspace_id, api/dependencies.py 680 to 779: resolved from request.state.workspace_id, the X-Workspace-ID header, or a single-workspace fallback; 400 if the user has several workspaces and named none, 409 if they have zero) | 200 BankReconcileResponse; 400 no filename / file over MAX_UPLOAD_SIZE (10 MB) / any ValueError from a parser or from "no transactions found"; 500 on any other exception, logged, detail withheld | Multipart file upload, streamed in 8 KB chunks with a running size check (36 to 41) so an oversized file is rejected before it is fully buffered |
GET /api/accounting/bank/statements (56 to 65) | ActiveUser | WorkspaceID | 200 List[BankStatementResponse] | limit/offset query params, default 50/0, ordered created_at desc (list_statements, service 1191 to 1202) |
GET /api/accounting/bank/statements/{statement_id} (68 to 79) | ActiveUser | WorkspaceID | 200 BankStatementDetailResponse (with transactions); 404 on ValueError ("Statement not found") | Workspace-filtered lookup; a valid id from another workspace 404s the same as a missing one (get_statement, service 1204 to 1216) |
PATCH /api/accounting/bank/transactions/{transaction_id}/confirm (82 to 96) | ActiveUser | WorkspaceID | 200 BankTransactionResponse; 404 on ValueError | Body BankTransactionConfirmRequest: match_status, optional matched_journal_entry_id/matched_invoice_id/suggested_account |
POST /api/accounting/bank/transactions/{transaction_id}/book (99 to 113) | ActiveUser | WorkspaceID | 200 {"journal_entry_id", "transaction_id"}; 400 on ValueError (missing tx, no 102x bank account) | Body BankTransactionBookRequest: fiscal_year_id, description, debit_account_id, optional vat_code/vat_rate |
Mounted mount_router(app, accounting_bank_router.router, prefix="/api/accounting", tags=["Accounting Bank"], surfaces=LABS_SURFACES) (app_fastapi.py 923); a normal authenticated Labs surface, not surface_exempt, unlike a webhook seam.
The five public service methods a rebuild reimplements, all on bank_reconciliation_service (services/accounting/bank_reconciliation_service.py 1067 to 1358): import_statement(db, workspace_id, user_id, file_bytes, filename) -> dict; list_statements(db, workspace_id, limit=50, offset=0) -> List[BankStatement]; get_statement(db, workspace_id, statement_id) -> BankStatement (raises ValueError); confirm_transaction(db, workspace_id, user_id, transaction_id, data: dict) -> BankTransaction (raises ValueError); book_transaction(db, workspace_id, user_id, transaction_id, data: dict) -> dict (raises ValueError).
Application logic
import_statement (1067 to 1189), in commit order:
- Detect format by filename extension first, magic bytes second:
.pdfor a%PDF-prefix routes to_parse_pdf_bank_statement;.xmlor content starting with<routes to_parse_camt053;.csv/.txtroutes to_parse_csv_bank_statement; anything else tries CAMT first and falls back to CSV on any exception (1072 to 1084). No transactions parsed ⇒ValueError("No transactions found in file")(1086 to 1087), the router turns this into 400. - Insert one
BankStatementrow anddb.flush()(1090 to 1107) so itsidexists for the transactions' foreign key, before any transaction is built. - Per parsed transaction, compute
dedup_key, skip it (countingskipped) if the key already appeared earlier in this same batch or already exists in the DB for this workspace (select(BankTransaction.id).where(workspace_id==, dedup_key==), 1113 to 1132); this is the duplicate-import guarantee at the application layer, backed by the unique constraint at the database layer. Surviving rows becomeBankTransactionobjects withmatch_status=unmatchedand aredb.added (1134 to 1154), then onedb.flush()(1156). - Auto-match every unmatched transaction in place (
_auto_match, see below). - AI-suggest for whatever is still unmatched, wrapped in its own
try/exceptso a failure here never aborts the import (1168 to 1171); out of scope for this recipe, see When to use. - Count
matched_countacrossmatched+suggestedstatuses, write it to the statement row,db.flush(), write one audit-log row (audit_service.log_action,"bank_statement","import", 1181 to 1185), thendb.commit()(1186); the entire import is one transaction; nothing here mirrors the auto-topup engine's claim-before-send pattern because no external side effect (no PSP call) happens on this path, only a best-effort local AI call already isolated in step 5. - Build and return
_build_reconcile_response(1364 to 1377): counts of matched/suggested/unmatched plus the full transaction list.
_auto_match (845 to 976), four passes per transaction, first match wins, run only on rows still unmatched:
- QR-reference: exact
AccountingInvoice.qr_referencematch within the workspace (deleted invoices excluded) ⇒matched,match_method="qr_ref", confidence1.00,matched_invoice_idset (851 to 867). - SCOR-reference: same shape against
AccountingInvoice.scor_reference(869 to 885). - Amount + date, ±2 days, against posted journal entries touching a bank account (887 to 929): the query joins
JournalEntryLineto anAccountingAccountwhoseaccount_number LIKE '102%', filtersentry_datewithin the 2-day window, and matches oncredit_amountfor a debit tx ordebit_amountfor a credit tx (the tx's outflow is the account's credit and vice versa); anouterjoinagainstBankTransactionexcludes any journal entry already linked to a different bank transaction, so one JE cannot be claimed twice. Hit ⇒matched,match_method="amount_date", confidence0.85. - Text similarity, Jaccard ≥ 0.4, ±30 days (931 to 975): tokenizes
counterparty_name + unstructured_ref + additional_info(_tokenize, lowercased words length > 2, 977 to 982) against up to 50 candidate journal entries'description + reference, same "not already claimed by another tx" exclusion, keeps the best-scoring candidate. Hit ⇒suggested(notmatched, a human must confirm),match_method="text", confidence = the rounded Jaccard score.
A transaction matching none of the four passes stays unmatched and falls to the out-of-scope AI phase.
confirm_transaction (1218 to 1252): loads the tx workspace-scoped (ValueError if missing or cross-workspace), applies match_status and any of matched_journal_entry_id/matched_invoice_id/suggested_account present in the payload; if the new status is matched and no match_method was already set, stamps match_method="manual" and match_confidence=1.00 (1241 to 1243); an explicit user confirmation on a row that arrived as suggested keeps its heuristic method/confidence unless the caller overrides it.
book_transaction (1254 to 1358): loads the tx workspace-scoped; requires the caller to name fiscal_year_id, description, debit_account_id; looks up the workspace's first non-deleted 102% account as the bank leg, ValueError("No bank account (102x) found in chart of accounts") if none exists (1274 to 1286); builds a human-readable reference BANK-<statement prefix>-<bank_ref or dedup_key[:8]> (1288 to 1291); creates one JournalEntry(status=draft, source_type="bank_import", source_id=tx.id) and exactly two JournalEntryLines; for a credit (money in) the bank line is debited and the chosen account credited, for a debit (money out) it is inverted (1293 to 1301, 1319 to 1340); marks the transaction booked, match_method="manual", match_confidence=1.00, links matched_journal_entry_id, flushes, audit-logs, commits (1344 to 1358).
Invariants
- A transaction is counted at most once per workspace: enforced twice, the batch-local
seen_keysset and the DB's ownuq_bank_transactions_ws_dedupunique constraint (import_statement1113 to 1132;test_import_statement_dedup_skips_duplicate_reimport). amounton aBankTransactionis always non-negative; direction, never the sign ofamount, carries the money's flow (every parser's finalabs()call;test_credit_entry_direction_and_sign,test_debit_entry_direction_and_counterparty).- A journal entry already linked to one bank transaction is never offered to
_auto_matchfor a second one, in either the amount/date or the text pass (theouterjoin(...).where(BankTransaction.id.is_(None))exclusion, 912 to 920 and 948 to 955; no test in the two files under Tests exercises two competing transactions for one journal entry directly, this is read from the query shape, not proven by a named test; a genuine gap, see Tests). book_transaction's generated journal entry always balances:sum(debit_amount) == sum(credit_amount)across its two lines, by construction of the credit/debit branch (test_book_transaction_credit_creates_balanced_je,test_book_transaction_debit_inverts_bank_side).- Every read and write in this recipe is workspace-scoped; a transaction, statement, or a
book_transaction/confirm_transactioncall from another workspace's id is treated as not found (test_get_statement_cross_workspace_raises,test_confirm_transaction_cross_workspace_raises,test_book_transaction_cross_workspace_raises,test_list_statements_is_workspace_scoped).
Frontend
The module that would render this, by id:
bookkeeping/ui/bookkeeping-financial-dashboard the workspace's bookkeeping surface; at 62ba4fc4 alchemy_labs' own
equivalent is frontend/src/pages/accounting/BankReconciliation.tsx
(626 lines), which calls all five routes above through
frontend/src/services/accountingApi.ts (uploadBankStatement,
listBankStatements/getBankStatement, confirmBankTransaction,
bookBankTransaction, lines 455-465) and renders the confirm/ignore
affordances per transaction (BankReconciliation.tsx 158, 170)
Minimum admin/debug surface (no dedicated route exists at this commit, only queries a human runs by hand): SELECT id, original_filename, file_format, transaction_count, matched_count, created_at FROM accounting_bank_statements WHERE workspace_id = :ws ORDER BY created_at DESC LIMIT 20 (import history); SELECT match_status, count(*) FROM accounting_bank_transactions WHERE workspace_id = :ws GROUP BY match_status (the reconciliation backlog at a glance); SELECT id, counterparty_name, amount, direction, booking_date FROM accounting_bank_transactions WHERE workspace_id = :ws AND match_status = 'unmatched' ORDER BY booking_date DESC (what still needs a human, once the out-of-scope AI phase has had its turn).
Security & tenancy
Auth dependency: ActiveUser (api/dependencies.py 566, chained off get_current_active_user → get_current_user, 187 to 349) on every route; bearer JWT, an httpOnly cookie, or an X-API-Key, checked in that order; 401 "Could not validate credentials" on anything unresolvable, 403 "Inactive user"/"User account is disabled" on a deactivated account. Tenancy: WorkspaceID (_get_workspace_id, 680 to 779) derives and access-checks the workspace per request; from request.state.workspace_id when WorkspaceMiddleware already resolved it, from X-Workspace-ID re-validated against the user otherwise, or a single-workspace fallback; every query in the service filters on this workspace_id, there is no code path in this file that reads or writes a row by id alone.
Fail direction: unauthenticated closed (401/403 before any handler body runs); cross-tenant closed (a valid id from the wrong workspace reads as ValueError/404, never leaks the other workspace's row, per the Invariants tests); malformed input on the parse side is comparatively open, the CAMT/CSV/PDF parsers are heuristic by design and degrade to "0 transactions found" (a 400) rather than refusing a file outright, there is no allowlist on file content beyond the 10 MB size cap and defusedxml guarding the XML path against entity-expansion/XXE. Secrets and PII at rest: none of this recipe's own; no card data, no PSP token; the transaction rows do carry bank-supplied free text (counterparty_name, unstructured_ref, additional_info) which can contain a real name or IBAN by the nature of a bank statement, stored in plain columns, not redacted, no different from the rest of accounting_*. Rate limits: none dedicated to this router; the only throttle is the 10 MB upload cap and the AI_SUGGEST_MAX=30 cap on the out-of-scope phase. Attacker-controlled inputs (all behind ActiveUser+WorkspaceID, so this is "a malicious tenant", not "the public"): the uploaded file's bytes and filename, parsed leniently by three different heuristic parsers, none of which is proven safe against a maliciously crafted PDF beyond what pypdf/pdfplumber themselves guarantee, that guarantee is not measured in this recipe.
Tests
Run 2026-09-12 at 62ba4fc4, from alchemy_labs/backend with the .venv checked into that repo, against the already-running Postgres 14 test service on localhost:5434 (docker ps shows deap-postgres-test, matching _shared/test-layout's convention):
python -m pytest tests/v2/coverage/test_voila_g9_bank_reconciliation_statement_parsing_camt_053_csv_pdf_amount_date_parsing_service_level_pure_parsing_.py \
-q --no-cov -o addopts="" -p no:cacheprovider
prints 73 passed (0 failed; 58 def test_ methods, 4 @pytest.mark.parametrize decorators expand this to 73). Hermetic: no DB, no network, no auth; these call the parser methods (_parse_swiss_amount, _parse_csv_date, _detect_csv_delimiter, _parse_csv_bank_statement, _parse_camt053, _find_column, _infer_direction_from_text) directly on the module singleton. Known-answer vectors: the apostrophe/U+2019/space thousands separators, dd.mm.yyyy/ISO/2-digit-year dates, the CRDT/DBIT sign flip on a CAMT balance, a 27-digit QR reference and an RF-prefixed SCOR reference extracted from free text, latin-1 CSV fallback decoding.
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
python -m pytest tests/v2/coverage/test_voila_g10_bank_reconciliation_import_list_confirm_book_auto_match_service_level_db_.py \
-q --no-cov -o addopts="" -p no:cacheprovider
prints 19 passed (0 failed; 20 lines match def test_, but one, def test_user(db_session) at line 172, is a @pytest.fixture, not a collected test, so 19 actually run). This file brings its own engine into a dedicated g10_recon Postgres schema (documented in its own header comment, 45 to 59) rather than the shared session-scoped test_engine, because the full model graph's create_all() double-emits a CREATE TYPE for a shared enum name and aborts; that workaround is specific to this test file, not part of the recipe's production code. It exercises real request→persist→read flows: import_statement persisting a two-row CSV blob and re-import dedup, list_statements/get_statement tenant isolation, confirm_transaction status/account writes, book_transaction's balanced-JE guarantee in both directions, and all three _auto_match passes plus the no-candidate case, against a real chart of accounts. The AI-suggest phase is neutered per-test via the no_ai fixture monkeypatching _ai_suggest to a no-op (206 to 216), so no test in either file makes a live Mistral call, consistent with that phase being out of this recipe's scope.
Gap: no test in either file directly proves the "a journal entry claimed by one bank transaction cannot be claimed by a second" exclusion in _auto_match passes 3 and 4 (the invariant above is read from the query's outerjoin/is_(None) shape, not exercised by a two-competing-transactions test); no Playwright/E2E spec exists for this router (there is no browser surface measured in this recipe, only frontend/src/pages/accounting/BankReconciliation.tsx's existence, not its rendering, was checked).
Deploy & ops
Config keys: none, this router reads no environment variable (verified grep -n "BANK" backend/core/config.py returns nothing at 62ba4fc4); the two tunables (MAX_UPLOAD_SIZE, AI_SUGGEST_MAX) are Python constants, changing them means a deploy. Migration: 20260226_bank_reconciliation (down_revision = '20260223_standing_orders'), later merged with workspace_rls_001 at mergepoint 0aad13a9c18d (merge_workspace_audit_with_bank_reconciliation, per alembic history at this commit); the live single head is prima_device_codes_001 (python -m alembic heads prints exactly that one line, confirming no dangling branch and no multiple-heads state). Two Postgres-native enums, banktransactiondirection and banktransactionmatchstatus, created with create_type=False-then-.create(checkfirst=True) specifically to avoid a double-CREATE TYPE (the migration's own comment, 24 to 28); the same class of enum-name collision the G10 test file works around for its own throwaway schema. Scheduler: none, this recipe has no background job, every transition happens synchronously inside a request. Argus-style checks: none exist for this router at this commit (not determinable as present; stated plainly rather than invented). Rollback: downgrade() drops both tables and both enums (97 to 103); since a bank statement row cascades to its transactions (ON DELETE CASCADE), a rollback loses the imported data outright, there is no soft-delete on either table.
Gotchas
- 2026-09-12 (measured at 62ba4fc4,
bank_reconciliation_service.py221):_parse_camt_entrywrites"currency": "CHF"unconditionally on every transaction it builds, with a# TODO: Extract from parent statement currencycomment right there; a non-CHF camt.053 statement (the parser reads the statement's ownCcycorrectly into the statement row,_parse_camt053line 88) still produces transaction rows all labeled CHF. This is a real, currently-shipping bug, not a hypothetical; the two shipped test files do not cover a non-CHF CAMT fixture, so nothing currently fails because of it. - 2026-09-12 (measured at 62ba4fc4): the PDF parser's bank-format detection (
_detect_pdf_bank_format, 527 to 544) and per-format amount-direction heuristic (_determine_pdf_amount_direction, 756 to 820) are keyword- and position-based, not a byte-for-byte layout parser; the module's own docstring on_extract_pdf_textexplains why: PostFinance's embedded fonts makepypdfemit one character per line for some exports, detected by a >50%-of-lines-are-1-to-2-chars heuristic (456 to 461) and recovered via apdfplumberfallback. Neither test file in this recipe's scope exercises an actual PDF fixture (both G9 and G10 test CAMT and CSV only), so the PDF path's correctness is unverified by the tests measured here; a genuine gap, not something this recipe can respectably claim proof for. - 2026-09-12 (measured at 62ba4fc4):
book_transactionfinds the bank account withAccountingAccount.account_number.like("102%")and takes the first match with no ordering (.scalars().first(), 1275 to 1284); a workspace with more than one 1020-series account (say 1020 and 1025) gets whichever one Postgres happens to return first, not a deterministic "primary" account._auto_match's amount/date pass has the identicallike("102%")pattern with the same ambiguity (907).
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/accounting/bank_reconciliation_service.py(1408 lines): module docstring (1 to 5); CAMT parser (_detect_namespace56 to 70,_parse_camt05372 to 132,_parse_camt_entry134 to 230); CSV parser (_detect_csv_delimiter236 to 241,_parse_swiss_amount243 to 251,_parse_csv_bank_statement253 to 378,_find_column380 to 385,_parse_csv_date387 to 397); PDF parser (_extract_pdf_text434 to 477,_parse_pdf_bank_statement479 to 525,_detect_pdf_bank_format527 to 544,_extract_pdf_iban/_extract_pdf_currency/_extract_pdf_period/_extract_pdf_balance546 to 581,_extract_pdf_transactions583 to 721,_is_pdf_header_footer723 to 754,_determine_pdf_amount_direction756 to 820,_infer_direction_from_text822 to 839); matching (_auto_match845 to 976,_tokenize977 to 982); AI suggestion, out of scope (_ai_suggest993 to 1032,_call_mistral_for_account1034 to 1061); public methods (import_statement1067 to 1189,list_statements1191 to 1202,get_statement1204 to 1216,confirm_transaction1218 to 1252,book_transaction1254 to 1358); helpers (_build_reconcile_response1364 to 1377,_text/_decimal/_parse_datestaticmethods 1379 to 1405); module singleton (1408).alchemy_labs/backend/api/routers/accounting_bank_router.py(113 lines, whole file in scope):MAX_UPLOAD_SIZE(21);upload_bank_statement(24 to 53);list_bank_statements(56 to 65);get_bank_statement(68 to 79);confirm_bank_transaction(82 to 96);book_bank_transaction(99 to 113).alchemy_labs/backend/models/accounting_models.py:BankTransactionDirection(84 to 87);BankTransactionMatchStatus(90 to 96);BankStatement(583 to 610);BankTransaction(613 to 651).alchemy_labs/backend/schemas/accounting_schemas.py(Bank Reconciliation section, 728 to 806):BankTransactionResponse(732 to 757);BankStatementResponse(760 to 778);BankStatementDetailResponse(781 to 782);BankTransactionConfirmRequest(785 to 789);BankTransactionBookRequest(792 to 797);BankReconcileResponse(800 to 806).alchemy_labs/backend/migrations/versions/20260226_add_bank_reconciliation.py(whole file, 103 lines):revision = '20260226_bank_reconciliation',down_revision = '20260223_standing_orders'.alchemy_labs/backend/app_fastapi.py: the router import comment (109), the mount call (923).alchemy_labs/backend/api/dependencies.py:ActiveUser(566),DatabaseSession(572),WorkspaceID(779),_get_workspace_id(680 to 779),get_current_user(187 to 337, 401 path at 196 to 199),get_current_active_user(338 to 349, 403 path).alchemy_labs/frontend/src/pages/accounting/BankReconciliation.tsx(626 lines) andalchemy_labs/frontend/src/services/accountingApi.ts(bank calls at 455 to 465): the app's own rendering of this surface, cited for context under Frontend, not part of this recipe's backend scope.- Tests as listed under Tests:
alchemy_labs/backend/tests/v2/coverage/test_voila_g9_bank_reconciliation_statement_parsing_camt_053_csv_pdf_amount_date_parsing_service_level_pure_parsing_.py(446 lines),test_voila_g10_bank_reconciliation_import_list_confirm_book_auto_match_service_level_db_.py(821 lines).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: the CAMT parser hardcodes transaction currency to CHF regardless of the statement's actual currency (Gotchas); the PDF path is exercised by neither shipped test file; book_transaction's and _auto_match's bank-account lookup is non-deterministic when a workspace has more than one 102x account; no admin/debug route exists for the three queries under Frontend; the two-transactions-cannot-claim-one-journal-entry exclusion in _auto_match has no dedicated test.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc4 with the repo's own .venv and the test Postgres already listening on :5434 (docker ps | grep postgres-test). Expected observations are exact, as measured 2026-09-12.
python -m pytest tests/v2/coverage/test_voila_g9_bank_reconciliation_statement_parsing_camt_053_csv_pdf_amount_date_parsing_service_level_pure_parsing_.py -q --no-cov -o addopts="" -p no:cacheproviderprints73 passed.TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db python -m pytest tests/v2/coverage/test_voila_g10_bank_reconciliation_import_list_confirm_book_auto_match_service_level_db_.py -q --no-cov -o addopts="" -p no:cacheproviderprints19 passed.grep -c "^def test_" tests/v2/coverage/test_voila_g9_bank_reconciliation_statement_parsing_camt_053_csv_pdf_amount_date_parsing_service_level_pure_parsing_.pyprints1(the module-leveltest_module_singleton_is_service_instance);grep -c "^ def test_"on the same file prints57(the remaining tests are methods on classes), 57 + 1 = 58 total, matching the "58def test_methods" figure under Tests;grep -c "@pytest.mark.parametrize"prints4.python -m alembic headsprints exactly one line,prima_device_codes_001 (head), andpython -m alembic history | grep 20260226_bank_reconciliationshows it entering the chain at a mergepoint (0aad13a9c18d) rather than as a dangling branch.grep -n '"currency": "CHF"' services/accounting/bank_reconciliation_service.pyprints three lines (221,356,370); only221, inside_parse_camt_entry, carries the# TODO: Extract from parent statement currencycomment and is the genuine bug (the CAMT statement's real currency is parsed correctly elsewhere and then ignored here);356/370are the CSV parser's per-transaction and statement-level defaults, a reasonable choice since a Swiss CSV export carries no currency field to read in the first place, not the same defect. The PDF parser is not in this list: its per-transaction dict uses thecurrencyvariable_extract_pdf_currencyactually parsed (line 710), not a literal.grep -n "account_number.like(\"102%\")" services/accounting/bank_reconciliation_service.pyreturns two lines (907, 1279), confirming both the auto-match and the booking path share the same non-deterministic bank-account lookup noted in Gotchas.
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.