Files
finance/docs/adr/0003-versioned-statement-imports.md
T
k1nq 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
Implement Gitea CI and real statement imports
2026-09-18 17:59:02 +05:00

5.3 KiB
Raw Blame History

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 (posted or hold). 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_posted creates a normal ledger operation through the ledger services (honoring closed-period rules) with a provenance source-record link; discard_as_cancelled creates 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_on date; 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.