Implement Gitea CI and real statement imports
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
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
This commit is contained in:
@@ -2,29 +2,39 @@
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-11
|
||||
- **Scope:** statement import pipeline, parse provenance, deduplication, apply/revert semantics
|
||||
- **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.
|
||||
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.
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
# Parser Private Fixture Gate
|
||||
|
||||
Real bank statements contain personal financial data and are **never committed**
|
||||
to Git (`.gitignore` at the repo root covers this; keep fixture directories
|
||||
outside the repository). CI runs synthetic fixtures only.
|
||||
to Git. CI runs synthetic fixtures only. Private fixtures may live either in an
|
||||
external directory or in the ignored repository-local `examples/` directory.
|
||||
|
||||
## Contract
|
||||
|
||||
- Synthetic parser inputs live with the backend tests and run in CI.
|
||||
- Real statement files stay in a local `PRIVATE_FIXTURES_DIR`, never in Git, never in shared storage without encryption.
|
||||
- Real statement files stay in `PRIVATE_FIXTURES_DIR` or the ignored repository-local `examples/` directory, never in Git and never in shared storage without encryption.
|
||||
- Each private fixture directory contains an ignored `manifest.json` beside the statements. For each fixture it records the filename, adapter ID, parser version, period, row counts, control totals, balances, and HOLD totals.
|
||||
- The manifest and its expected values are private financial data. They must not appear in tracked test parameters, documentation, CI inputs, artifacts, or logs.
|
||||
- Private assertions report only a filename, adapter ID, dates, counts, and the name of a mismatched aggregate. They never render parsed rows, descriptions, names, account/card numbers, extracted pages, or expected financial values.
|
||||
- The private suite runs explicitly, on the developer's machine:
|
||||
|
||||
```sh
|
||||
PRIVATE_FIXTURES_DIR=/secure/path/fixtures uv run pytest -m private_fixtures
|
||||
```
|
||||
|
||||
(Exact marker/task names are finalized by the backend workstream; the entry point contract is `PRIVATE_FIXTURES_DIR` + a dedicated pytest marker.)
|
||||
When `PRIVATE_FIXTURES_DIR` is unset, the suite may use the ignored
|
||||
repository-local `examples/` directory. The dedicated marker keeps this suite
|
||||
out of normal CI collection.
|
||||
|
||||
## Beta gate per adapter/contract
|
||||
|
||||
@@ -35,11 +40,14 @@ gate must not be enabled or advertised, even if synthetic tests pass (plan
|
||||
|
||||
| Adapter / parser version | Private periods passed | Gate |
|
||||
| --- | --- | --- |
|
||||
| Alfa debit XLSX | — | **not passed** |
|
||||
| Alfa credit XLSX | — | **not passed** |
|
||||
| Ozon debit PDF | — | **not passed** |
|
||||
| Ozon credit PDF | — | **not passed** |
|
||||
| Rocketbank debit PDF | — | **not passed** |
|
||||
| Alfa debit XLSX / 2 | 1 | **not passed** |
|
||||
| Alfa credit XLSX / 1 (unregistered) | 0 | **not passed** |
|
||||
| Ozon debit PDF / 2 | 1 | **not passed** |
|
||||
| Ozon credit PDF / 1 (unregistered) | 0 | **not passed** |
|
||||
| Rocketbank debit PDF / 2 | 1 | **not passed** |
|
||||
|
||||
No adapter is currently gated. Bank layout drift resets the gate: it must create
|
||||
a new parser version and a review diff, never reinterpret historical data.
|
||||
The three debit contracts each pass one local private fixture deterministically,
|
||||
but no adapter is currently gated. A second independent real period/layout is
|
||||
still required for each contract. Bank layout drift resets the gate: it must
|
||||
create a new parser version and a review diff, never reinterpret historical
|
||||
data.
|
||||
|
||||
Reference in New Issue
Block a user