Skip to content

Finance reconciliation: vendor parsers + deterministic matcher (closes unimatrix27/ideas#22) - #3

Open
unimatrix27 wants to merge 5 commits into
mainfrom
feat/finance-parsers-and-matcher
Open

Finance reconciliation: vendor parsers + deterministic matcher (closes unimatrix27/ideas#22)#3
unimatrix27 wants to merge 5 commits into
mainfrom
feat/finance-parsers-and-matcher

Conversation

@unimatrix27

Copy link
Copy Markdown
Owner

Summary

Implements the matching half of the Mode-B reconcile agent. Two deliverables, scope-locked to unimatrix27/ideas#22:

  1. Vendor parsers (finance/parsers/) — pure functions returning extracted_json for Sipgate, Notion, Lucky Penny, and Vodafone (PDF + portal notification). Single dispatch via finance.parsers.parse(candidate). Failed parses emit parse_status='failed' | 'portal_required' with a parse_error, never silently wrong fields (per fix: align threading docstring with implementation NousResearch/hermes-agent#27's honest-tool-boundaries rule).
  2. Deterministic matcher (finance/matcher.py) — walks open bank.transactions, scores every receipt_candidates row, writes decision_status='proposed' (or manual_needed for portal-only) to bank.receipt_matches with 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):

Parsers are already pure (text → dict | None). For the matcher, write a thin DB adapter and mock it in tests. Fast, simple, no Postgres setup.

Rationale:

Files

Path Purpose
finance/parsers/__init__.py parse(candidate) dispatch + vendor detection cascade
finance/parsers/sipgate.py Rechnungsnummer / Rechnungsdatum / Rechnungsbetrag label parser
finance/parsers/notion.py Invoice number ZWLWGPDN-… + English date + Amount due €…
finance/parsers/lucky_penny.py Tax invoice + credit-note variants, Refunded invoice reference: for credit notes
finance/parsers/vodafone.py PDF parse with Höhe von …,… EUR anchor, plus parse_status='portal_required' for MeinVodafone notifications
finance/matcher.py Transaction / Candidate / ProposedMatch dataclasses, MatcherAdapter Protocol, InMemoryMatcherAdapter, PostgresMatcherAdapter, vendor cascade, scoring, idempotent upsert
finance/tests/test_parsers.py Per-vendor parser tests against NousResearch#24 fixtures (11 tests)
finance/tests/test_matcher.py Per-named-TX acceptance tests + invariants + idempotency (16 tests)

Per-acceptance-criterion checklist

Every named TX outcome from NousResearch#22's acceptance section is covered by a dedicated test:

  • Sipgate B4373121 → TX 56very_high / exact_invoice_number, reason_codes include invoice_no:B4373121, amount_eq:40.00, date_within:1d, mcc:4814. test_sipgate_b4373121_proposes_very_high_on_tx56.
  • Sipgate B4411208 → TX 31very_high / exact_invoice_number, reason_codes include invoice_no:B4411208, amount_eq:55.00. test_sipgate_b4411208_proposes_very_high_on_tx31.
  • Sipgate B4459838 → TX 5 already sent — matcher writes zero code-decided rows for TX 5. test_sipgate_b4459838_skipped_because_tx5_already_sent.
  • Lucky Penny 6945-10683 → TX 39high / exact_amount_date, reason_codes include invoice_no:6945-10683, amount_eq:59.50, mcc:5817. test_lucky_penny_6945_10683_proposes_high_on_tx39.
  • Lucky Penny credit note → TX 20high / refund_to_invoice, reason_codes include refund_ref:6945-10683, direction:refund, amount_eq:9.50. test_lucky_penny_credit_note_proposes_high_on_tx20.
  • Notion ZWLWGPDN-0002 → TX 66 already sent — zero code-decided rows for TX 66. test_notion_tx66_skipped_already_sent.
  • Notion TX 88 → no candidate exists — zero proposed rows (no Notion candidate matches by amount/period). test_notion_tx88_no_proposal_because_no_candidate_exists.
  • Vodafone TX 53 already sent — zero code-decided rows. test_vodafone_tx53_skipped_already_sent.
  • Vodafone TX 1 → BOTH manual_needed / portal_only AND very_high / exact_invoice_number on remittance — both rows verified, invoice_no_in_remittance:122064713086, portal_required:vodafone. test_vodafone_tx1_writes_portal_only_AND_invoice_in_remittance.
  • TX 27 (Vodafone, ignored=true) — matcher skips entirely despite remittance invoice number. test_vodafone_ignored_tx27_skipped.

Matcher invariants (per NousResearch#27)

Verified by dedicated tests:

  • Never writes 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.
  • Idempotent: re-running with no new state produces zero new rows. test_matcher_is_idempotent.
  • Updates existing reason_codes in place when signals change (no duplicate rows). test_matcher_updates_reason_codes_when_signals_change.
  • Ignored transactions skipped at iteration boundary, including those with remittance invoice numbers (TX 27).
  • Read-only against bank.transactions and bank.belege_sentPostgresMatcherAdapter only writes to bank.receipt_matches.

Vendor cascade + reason codes

The matcher emits vendor:<key> and mcc:NNNN (when present) on every proposal. Vendor identification cascade per NousResearch#22:

  1. counterparty_name substring (e.g. SIPGATE, NOTION LABS, PADDLE.NET* LUCKYPENNY, Vodafone GmbH)
  2. MCC from transactions.raw.merchant_category_code or remittance_information (MCC: 4814)
  3. Vendor-specific remittance regex (Vodafone 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

Signal Confidence match_type
Vodafone invoice number in transactions.remittance_information very_high exact_invoice_number (candidate_id NULL)
Candidate invoice number found in tx remittance very_high exact_invoice_number
Candidate has invoice_date + exact amount + date within ±3 days very_high exact_invoice_number
Exact amount + date within ±3 days (no specific invoice_date; e.g. Lucky Penny period_end) high exact_amount_date
Exact amount + billing period covers tx date high vendor_period
Billing period covers tx date (no amount match) medium vendor_period
Credit-note refund amount + tx is credit high refund_to_invoice
Portal-required candidate + non-ignored vendor tx with no sent match n/a portal_onlymanual_needed

The 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 medium period band.

Test run

$ python -m pytest finance/tests/ -q -o addopts=''
................................................                         [100%]
48 passed in 0.05s

Out of scope (intentionally not in this PR)

Notes for reviewers

🤖 Generated with Claude Code

unimatrix27 and others added 5 commits May 11, 2026 16:25
…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>
@github-actions

Copy link
Copy Markdown

🔎 Lint report: feat/finance-parsers-and-matcher vs origin/main

ruff

Total: 1 on HEAD, 0 on base (🆕 +1)

🆕 New issues (1):

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant