Finance reconciliation: vendor parsers + deterministic matcher (closes unimatrix27/ideas#22) - #3
Open
unimatrix27 wants to merge 5 commits into
Open
Finance reconciliation: vendor parsers + deterministic matcher (closes unimatrix27/ideas#22)#3unimatrix27 wants to merge 5 commits into
unimatrix27 wants to merge 5 commits into
Conversation
…ema and idempotent backfill Implements unimatrix27/ideas#20 — data foundation only, no matching logic. Migration: creates two new tables in the existing bank.* schema with the columns, checks, and indexes specified in NousResearch#20. Legacy tables (transactions, belege_sent, belege_to_send, belege_missing, match_proposals) are not touched. Rollback drops only what up created. Backfill: populates the new tables from three legacy sources and skips bank.match_proposals (out of scope per NousResearch#20). Idempotent on re-run via a unique partial index on legacy_belege_sent_id and legacy_meta lookups for rows without it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-shot, public-fork-safe fixture pack so NousResearch#22 (parsers + matcher) can be implemented fully offline. Ships: - tests/fixtures/finance/<vendor>/<invoice>.txt + .meta.json for Sipgate, Notion, Lucky Penny (invoice + paired credit note), and Vodafone. - tests/fixtures/finance/vodafone/portal_notification_*.txt — body of one notification-only email (Vodafone is portal-only most months). - tests/fixtures/finance/transactions.jsonl — 11 named TX ids + the Google Ads kanban-task row; counterparty IBANs redacted to "DE**". - tests/fixtures/finance/beleg_match_samples.jsonl — 9 rows incl. all 3 via='manual_review' shapes verbatim (load-bearing for NousResearch#20's backfill tests). - tests/fixtures/finance/belege_sent_samples.jsonl — 9 rows covering each via value, >=2 with bank_tx_id IS NULL, >=2 with attachments. - finance/scripts/build_fixtures.py + README — the re-runnable extractor. Re-running build_fixtures.py against the same Supabase + mailbox state produces byte-identical output. The script is NOT run in CI; it needs SUPABASE_DB_URL + the LINEO_MS_* delegated token bundle.
…deas#22) Pure-function parsers for Sipgate, Notion, Lucky Penny, and Vodafone under finance/parsers/, plus finance/matcher.py — the deterministic candidate generator that walks open bank.transactions and writes 'proposed' (or 'manual_needed' for portal-only) rows to bank.receipt_matches with stable reason codes. Matcher invariants (anchored in NousResearch#27): - Never writes 'approved' / 'sent' / 'rejected' / 'ignored'. - Skips ignored transactions and txs with an existing approved/sent match. - Idempotent: re-runs touch nothing unless reason_codes change. - decided_by='code' for everything it writes. Strategy (A) per the implementing-agent brief: tests run fully offline against InMemoryMatcherAdapter loaded from the NousResearch#24 fixture pack. A thin PostgresMatcherAdapter is included for the cron entrypoint, mirroring PR #1's psycopg2 style — no ORM, no LLM, no network. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🔎 Lint report:
|
| Rule | Count |
|---|---|
PLW1514 |
1 |
First entries
finance/scripts/build_fixtures.py:138: [PLW1514] `pathlib.Path(...).read_text` without explicit `encoding` argument
✅ Fixed issues: none
Unchanged: 0 pre-existing issues carried over.
ty (type checker)
Total: 7982 on HEAD, 7967 on base (🆕 +15)
🆕 New issues (15):
| Rule | Count |
|---|---|
unresolved-import |
12 |
invalid-argument-type |
1 |
possibly-missing-submodule |
1 |
invalid-assignment |
1 |
First entries
finance/verify_backfill.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/verify_backfill.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/tests/test_matcher.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/scripts/build_fixtures.py:52: [unresolved-import] unresolved-import: Cannot resolve imported module `pymupdf`
finance/tests/test_fixture_pack.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/tests/test_parsers.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/scripts/build_fixtures.py:51: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/backfill_receipts.py:29: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/matcher.py:88: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `date`, found `(Any & ~str & ~datetime) | None | date`
finance/scripts/build_fixtures.py:50: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/matcher.py:597: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/backfill_receipts.py:30: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/migrate.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/scripts/build_fixtures.py:218: [possibly-missing-submodule] possibly-missing-submodule: Submodule `error` might not have been imported
finance/parsers/vodafone.py:124: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["gross_amount"]` and value of type `int | float` on object of type `dict[str, str]`
✅ Fixed issues: none
Unchanged: 4210 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
This was referenced May 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the matching half of the Mode-B reconcile agent. Two deliverables, scope-locked to unimatrix27/ideas#22:
finance/parsers/) — pure functions returningextracted_jsonfor Sipgate, Notion, Lucky Penny, and Vodafone (PDF + portal notification). Single dispatch viafinance.parsers.parse(candidate). Failed parses emitparse_status='failed' | 'portal_required'with aparse_error, never silently wrong fields (per fix: align threading docstring with implementation NousResearch/hermes-agent#27's honest-tool-boundaries rule).finance/matcher.py) — walks openbank.transactions, scores everyreceipt_candidatesrow, writesdecision_status='proposed'(ormanual_neededfor portal-only) tobank.receipt_matcheswith stable JSON reason codes.Closes unimatrix27/ideas#22. Do not merge — needs review per the issue's acceptance criteria. Depends on PR #1 (schema, currently open) and PR #2 (fixture pack, currently open); both branches are merged into this one so the working tree has both prerequisites.
DB strategy: (A) — pure-function tests, adapter mocked
Per the implementing-agent brief, two strategies were viable. I picked (A):
Rationale:
InMemoryMatcherAdapterloaded from the Endless Terminals Environment Integration NousResearch/hermes-agent#24 fixture JSONL. State transitions are unit-testable.PostgresMatcherAdapterships alongside the in-memory one for the cron entrypoint — it mirrors PR Finance reconciliation: schema + backfill (closes unimatrix27/ideas#20) #1's psycopg2/RealDictCursor style and writes only tobank.receipt_matches(reads-only againstbank.transactionsandbank.receipt_candidates). Terminal backend fanout pr2 - PENDING NousResearch/hermes-agent#23's toolbox can callrun_matcher(PostgresMatcherAdapter(conn))directly.receipt_candidate_id IS NULLand the legacy_meta lookup pattern.Files
finance/parsers/__init__.pyparse(candidate)dispatch + vendor detection cascadefinance/parsers/sipgate.pyRechnungsnummer/Rechnungsdatum/Rechnungsbetraglabel parserfinance/parsers/notion.pyInvoice number ZWLWGPDN-…+ English date +Amount due €…finance/parsers/lucky_penny.pyRefunded invoice reference:for credit notesfinance/parsers/vodafone.pyHöhe von …,… EURanchor, plusparse_status='portal_required'for MeinVodafone notificationsfinance/matcher.pyTransaction/Candidate/ProposedMatchdataclasses,MatcherAdapterProtocol,InMemoryMatcherAdapter,PostgresMatcherAdapter, vendor cascade, scoring, idempotent upsertfinance/tests/test_parsers.pyfinance/tests/test_matcher.pyPer-acceptance-criterion checklist
Every named TX outcome from NousResearch#22's acceptance section is covered by a dedicated test:
B4373121→ TX 56 —very_high/exact_invoice_number, reason_codes includeinvoice_no:B4373121,amount_eq:40.00,date_within:1d,mcc:4814.test_sipgate_b4373121_proposes_very_high_on_tx56.B4411208→ TX 31 —very_high/exact_invoice_number, reason_codes includeinvoice_no:B4411208,amount_eq:55.00.test_sipgate_b4411208_proposes_very_high_on_tx31.B4459838→ TX 5 alreadysent— matcher writes zero code-decided rows for TX 5.test_sipgate_b4459838_skipped_because_tx5_already_sent.6945-10683→ TX 39 —high/exact_amount_date, reason_codes includeinvoice_no:6945-10683,amount_eq:59.50,mcc:5817.test_lucky_penny_6945_10683_proposes_high_on_tx39.high/refund_to_invoice, reason_codes includerefund_ref:6945-10683,direction:refund,amount_eq:9.50.test_lucky_penny_credit_note_proposes_high_on_tx20.ZWLWGPDN-0002→ TX 66 alreadysent— zero code-decided rows for TX 66.test_notion_tx66_skipped_already_sent.test_notion_tx88_no_proposal_because_no_candidate_exists.sent— zero code-decided rows.test_vodafone_tx53_skipped_already_sent.manual_needed/portal_onlyANDvery_high/exact_invoice_numberon remittance — both rows verified,invoice_no_in_remittance:122064713086,portal_required:vodafone.test_vodafone_tx1_writes_portal_only_AND_invoice_in_remittance.test_vodafone_ignored_tx27_skipped.Matcher invariants (per NousResearch#27)
Verified by dedicated tests:
approved/sent/rejected/ignored.test_matcher_never_writes_approved_or_sent_or_rejected.decided_by='code'for everything matcher-written.test_matcher_decided_by_is_always_code.test_matcher_is_idempotent.reason_codesin place when signals change (no duplicate rows).test_matcher_updates_reason_codes_when_signals_change.bank.transactionsandbank.belege_sent—PostgresMatcherAdapteronly writes tobank.receipt_matches.Vendor cascade + reason codes
The matcher emits
vendor:<key>andmcc:NNNN(when present) on every proposal. Vendor identification cascade per NousResearch#22:counterparty_namesubstring (e.g.SIPGATE,NOTION LABS,PADDLE.NET* LUCKYPENNY,Vodafone GmbH)transactions.raw.merchant_category_codeorremittance_information(MCC: 4814)Rechnungsnr:)Reason-code tokens are stable strings, no prose, JSON-array-shaped — both humans and the LLM agent (NousResearch#25) consume them directly.
Scoring rules
transactions.remittance_informationvery_highexact_invoice_number(candidate_id NULL)very_highexact_invoice_numberinvoice_date+ exact amount + date within ±3 daysvery_highexact_invoice_numberhighexact_amount_datehighvendor_periodmediumvendor_periodhighrefund_to_invoiceportal_only→manual_neededThe wide-vendor-only fallback was deliberately dropped — it produced too much cross-month noise (every Sipgate candidate against every Sipgate tx) and the spec's "over-proposes deliberately" still holds via the
mediumperiod band.Test run
Out of scope (intentionally not in this PR)
send_match/approve_match/flag_anomaly(Terminal backend fanout pr2 - PENDING NousResearch/hermes-agent#23).Notes for reviewers
main, those merge commits can be dropped in a rebase.finance/scripts/build_fixtures.py(from PR Finance reconciliation: fixture pack (closes unimatrix27/ideas#24) #2) is unchanged.Vodafone TX 27case (ignored) was added as an explicit test even though it wasn't in Env robustness: context-safe prompting + tool arg normalization NousResearch/hermes-agent#22's acceptance list — the issue mentions the ignored-skip invariant and Vodafone is the easiest vendor to verify it against.🤖 Generated with Claude Code