e43428c1a2
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 20s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Failing after 24s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 1m34s
5.3 KiB
5.3 KiB
ADR 0003: Versioned statement imports
- Status: Accepted
- Date: 2026-09-11
- Scope: statement import pipeline, parse provenance, deduplication, HOLD lifecycle, apply/revert semantics
Context
Five bank statement contracts must be parsed (Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF). Bank layouts drift over time. Money facts must never change until the user has reviewed the parse; losing a real operation is worse than holding a duplicate for review. The HTTP process must not parse files. Banks additionally report pending (HOLD) transactions that are not yet booked; treating them as settled would misstate balances.
Decision
- An uploaded statement document is immutable: magic/MIME/size/hash checks, then storage encrypted per ADR 0004. Full extracted text is never persisted — only allowlisted statement fields plus page/row coordinates.
- Every parse produces a versioned parse run (adapter + parser version) yielding immutable source records with document origin. A reparse creates a new run and diff; previous runs are never mutated.
- Each source row carries an immutable bank status (
postedorhold). HOLD rows are preserved exactly as extracted and never create ledger operations during normal import confirmation. - State machine:
uploaded → queued → processing → review_required/ready → confirmed | failed. Confirm applies the whole run atomically in a single DB transaction, creating only new operations/postings; existing duplicates only gain additional source links. Revert deletes exactly what that import created, with cascade reopen of affected periods. - Deduplication is conservative: auto-link only a stable bank identifier or a single unambiguous match; ambiguous candidates stay pending until the user decides.
- Reconciliation compares parsed posted inflow/outflow with the bank's declared totals, compares HOLD totals separately, and — where opening and closing balances exist — requires
opening + posted inflow − posted outflow = closing. Any mismatch stays review-blocking with stable diagnostic fields. - HOLD lifecycle: each HOLD source record carries at most one tenant-scoped, append-only
HoldResolution(matched_posted,confirmed_as_posted,discarded_as_cancelled) with optional resolving source record, operation, user, and a required reason for manual actions. The extracted HOLD row itself is never overwritten. - Automatic settlement matching: when a new statement is confirmed, its posted records are conservatively matched — same space, mapped account, and statement contract only — to unresolved HOLDs from confirmed imports. A stable external ID wins; otherwise a single unambiguous date/amount/direction/normalized-description fingerprint match is required. Any ambiguity leaves the HOLD unresolved; the resolution is written only at confirmation of the resolving statement. Reverting that statement removes the automatic match.
- Manual resolution: two CSRF-protected backend actions, both requiring a reason and (for booking) a mapped account:
confirm_as_postedcreates a normal ledger operation through the ledger services (honoring closed-period rules) with a provenance source-record link;discard_as_cancelledcreates none. Both write audit events. A later import of the same posted row dedupes onto the manually created operation instead of duplicating it. - Close blocking: unresolved HOLDs from confirmed imports block closing only the budget month containing their
occurred_ondate; manual confirmation may then expose the normal uncategorized-expense blocker until the created operation is categorized. - Bank layout drift produces a new parser version with an explicit review diff — historical data is never silently reinterpreted. An adapter is beta-eligible only after the private fixture gate (see
docs/parsers/private-fixture-gate.md). - Parsing runs in the Celery worker, isolated and resource-limited (memory, time, pages, rows, cells).
Consequences
- No row influences the ledger before explicit confirm; this invariant is enforced by tests (import confirm/revert idempotence).
- Unresolved HOLD rows remain visible in the import detail API (with optional resolution metadata) and keep blocking their month until settled; a HOLD can only be cleared by a unique posted match or an explicit manual action.
- Parse runs and source records grow storage; acceptable at beta scale, trimmed only with the source file deletion workflow.
- Adapter code must explicitly detect its layout; a mismatch fails with stable diagnostics instead of guessing.
- Future UI (deferred by design): the frontend must show HOLD rows with their status and resolution state and offer both manual resolution actions. The backend response fields (
bankStatus,holdResolution) are optional, backward-compatible additions; no UI changes ship in this workstream.
Alternatives rejected
- In-request synchronous parsing — blocks HTTP workers on untrusted files.
- Mutable "latest parse" overwrites — destroys provenance and diffability.
- Aggressive auto-dedup — risks dropping real operations.
- Mutating the extracted HOLD row in place when it posts — loses the original bank fact; append-only resolution metadata preserves it.
- Treating HOLDs as ledger operations on import — misstates balances before the bank books the transaction.