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

This commit is contained in:
k1nq
2026-09-18 17:59:02 +05:00
parent c85b12b95e
commit e43428c1a2
25 changed files with 19256 additions and 6208 deletions
+12 -2
View File
@@ -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.
+19 -11
View File
@@ -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.