Finance reconciliation: indexer (closes unimatrix27/ideas#21) - #4
Open
unimatrix27 wants to merge 8 commits into
Open
unimatrix27 wants to merge 8 commits into
unimatrix27 wants to merge 8 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>
…feat/finance-indexer
…' into feat/finance-indexer
Mode-B receipt ingest pipeline for the finance reconciliation feature (NousResearch#27). Pulls /messages/delta from configured Microsoft Graph mailboxes, downloads PDF attachments once (SHA-256 dedupe), extracts text once (pymupdf default; marker-pdf gated behind FINANCE_INDEXER_ALLOW_MARKER), and lands one row per attachment in bank.receipt_candidates. Senders on a portal-required allowlist with no PDF attachment land with parse_status='portal_required'. Implementation mirrors PR #3's MatcherAdapter pattern: an IndexerAdapter Protocol with in-memory + Postgres implementations, so tests stay offline. Reuses tools/microsoft_graph_client.py via a delegated DelegatedTokenProvider that quacks like the upstream MicrosoftGraphTokenProvider (refresh-token grant against the lineo-ms-tokens bundle). Migration 002_indexer_state adds bank.indexer_state keyed by a composite mailbox::folder string — Graph rejects mailbox-wide /messages/delta with "Change tracking is not supported", so the indexer is folder-scoped. The well-known 'inbox' ID resolves regardless of mailbox locale. Same code path serves the CLI (python -m finance.indexer for cron) and NousResearch#23's run_indexer() wrapper (finance.indexer.run). Cron entry registered at ~/.hermes/cron/jobs.json with schedule "0 9-18 * * 1-5", invoking ~/.hermes/scripts/finance_indexer.sh (no_agent=True, deterministic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🔎 Lint report:
|
| Rule | Count |
|---|---|
PLW1514 |
2 |
First entries
finance/scripts/build_fixtures.py:138: [PLW1514] `pathlib.Path(...).read_text` without explicit `encoding` argument
finance/indexer.py:1324: [PLW1514] `pathlib.Path(...).read_text` without explicit `encoding` argument
✅ Fixed issues: none
Unchanged: 0 pre-existing issues carried over.
ty (type checker)
Total: 7994 on HEAD, 7967 on base (🆕 +27)
🆕 New issues (26):
| Rule | Count |
|---|---|
unresolved-import |
19 |
invalid-argument-type |
5 |
invalid-assignment |
1 |
possibly-missing-submodule |
1 |
First entries
finance/indexer.py:1075: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/scripts/build_fixtures.py:51: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/matcher.py:88: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `date`, found `(Any & ~str & ~datetime) | None | date`
finance/verify_backfill.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/indexer.py:1367: [invalid-argument-type] invalid-argument-type: Argument to `MicrosoftGraphClient.__init__` is incorrect: Expected `MicrosoftGraphTokenProvider`, found `DelegatedTokenProvider`
finance/migrate.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/backfill_receipts.py:30: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
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]`
finance/indexer.py:1400: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/tests/test_indexer.py:27: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/scripts/build_fixtures.py:50: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/indexer.py:876: [invalid-argument-type] invalid-argument-type: Argument to bound method `GraphFetcher.fetch_message` is incorrect: Expected `str`, found `Any | None`
finance/tests/test_fixture_pack.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/backfill_receipts.py:29: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
finance/indexer.py:704: [invalid-argument-type] invalid-argument-type: Argument to bound method `GraphFetcher.list_attachments` is incorrect: Expected `str`, found `Any | None`
finance/indexer.py:389: [unresolved-import] unresolved-import: Cannot resolve imported module `marker.models`
finance/matcher.py:597: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
finance/scripts/build_fixtures.py:218: [possibly-missing-submodule] possibly-missing-submodule: Submodule `error` might not have been imported
finance/indexer.py:783: [invalid-argument-type] invalid-argument-type: Argument to bound method `GraphFetcher.download_attachment` is incorrect: Expected `str`, found `Any | None`
finance/tests/test_parsers.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/indexer.py:388: [unresolved-import] unresolved-import: Cannot resolve imported module `marker.convert`
finance/indexer.py:368: [unresolved-import] unresolved-import: Cannot resolve imported module `pymupdf`
finance/scripts/build_fixtures.py:52: [unresolved-import] unresolved-import: Cannot resolve imported module `pymupdf`
finance/tests/test_matcher.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
finance/verify_backfill.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2.extras`
... and 1 more
✅ Fixed issues: none
Unchanged: 4210 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
11 tasks
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
Mode-B receipt ingest half of the finance reconciliation feature (parent: unimatrix27/ideas#27). One indexer, two entry points, no LLM:
python -m finance.indexer [--mailbox X] [--folder F] [--since YYYY-MM-DD] [--limit N]run_indexer()wrapper:finance.indexer.run(config=…, adapter=…, fetcher=…)Both reach the same
_run_asyncfunction. Closes unimatrix27/ideas#21. Do not merge — needs review per the issue's acceptance criteria.Depends on #1 (schema), #2 (fixtures), #3 (parsers). All three merge commits are in this branch; rebase them out once they land on
main.Files
finance/indexer.pyIndexerAdapterProtocol +InMemoryIndexerAdapter+PostgresIndexerAdapter;DelegatedTokenProviderthat plugs intotools/microsoft_graph_client.py;LocalBlobBackend(S3-style backend left as the documented pluggable extension point); pymupdf default + marker-pdf opt-in viaFINANCE_INDEXER_ALLOW_MARKERfinance/migrations/002_indexer_state.up.sql/.down.sqlbank.indexer_statekeyed by mailbox (compositemailbox::foldertext PK)finance/migrate.py00N_*.{up,down}.sqlin order; optionalprefixarg applies just one migrationfinance/tests/test_indexer.pyInMemoryIndexerAdapter+FakeGraphClient— one per acceptance bullet~/.hermes/scripts/finance_indexer.sh~/.hermes/cron/jobs.jsonid=691b68f89b0a(no_agent=True,script=finance_indexer.sh, schedule"0 9-18 * * 1-5"Europe/Berlin, deliver telegram) — second cron entry on this hostSpec note — Graph folder-scoped delta
The first live attempt against
/users/{mb}/messages/deltareturnedGraph's delta tracking is folder-scoped only. The indexer therefore walks
/users/{mb}/mailFolders/{folder}/messages/delta, withfolderdefaulting to the well-known"inbox"ID (which resolves to Posteingang inrechnung@and Inbox inmarketing@, regardless of locale). Neither mailbox currently exposes aBelegesubfolder (verified live), so the default folder list is["inbox"]; operators can pass--folderto add others.bank.indexer_stateis keyed by the compositemailbox::folderstring so the existing schema doesn't need a second column.Live smoke-run summaries
Run 1 (canonical
--limit 10, both mailboxes){"scanned": 10, "new": 10, "dedup_skipped": 0, "portal_required": 0, "parse_failed": 6}The 10 new candidates from
rechnung@: 4 Sipgate PDFs parsed cleanly (parse_status='ok',vendor='sipgate', invoice numbersB4373121/B4411208/B4459838/B4500117), 6 vendors not in NousResearch#22's parser list (Finovia, Captrader×2, Shine, DKB, GS-90159) honestly land asparse_status='failed'with the blob and SHA captured for later re-parse.marketing@'s delta was already empty after Run-3 below ran first; the very first end-to-end live run scanned all 10 from that mailbox too (0 new — no PDFs / portal hits in the first 10 messages there).Run 2 (immediate re-run, same params — idempotency)
{"scanned": 0, "new": 0, "dedup_skipped": 0, "portal_required": 0, "parse_failed": 0}SELECT count(*) FROM bank.receipt_candidatesbefore run = 81, after run = 81. Zero writes, as required.Run 3 (extended walk to surface a Vodafone notification)
{"scanned": 50, "new": 30, "dedup_skipped": 10, "portal_required": 1, "parse_failed": 29}The
dedup_skipped: 10is real SHA-256 dedupe — those are the 10 Run-1 attachments re-encountered after deleting only theindexer_staterow (the candidates were still there). The Vodafone portal_required row landed exactly as specified:Acceptance criteria
Run 1 above:
count_before=71, count_after=81(10 inserted). Run 2 above:count_before=81, count_after=81(0 inserted). Final idempotency tick after Run 3 also flat at 111.receipt_candidatesrow.test_attachment_sha_dedupe_across_runs: same PDF bytes arrive under two differentinternetMessageIds; second run reportsdedup_skipped=1, new=0. Live Run 3 also dedupe-skipped 10 attachments by SHA-256 against the candidates from Run 1.parse_status='portal_required', notfailed.Live: Run 3 surfaced 1 portal_required row (id=106), wired through
from_email=nicht.antworten@kundenservice.vodafone.com,vendor='vodafone', body anchored on the real"Deine Rechnung … findest Du in Deinem persönlichen Service-Portal MeinVodafone"phrasing. Test:test_vodafone_portal_required_path(loads the real PR Finance reconciliation: fixture pack (closes unimatrix27/ideas#24) #2 fixture text + meta).Reuses
tools.microsoft_graph_client.MicrosoftGraphClient's built-in retry/backoff. The indexer adds only delta-token-expiry recovery (a distinct concern — 410/syncStateNotFoundis a permanent rejection, not a transient retry).test_delta_token_expiry_recovery: pre-seeds a stale token + already-indexed candidate, theFakeGraphClientraises a 410-shaped error on the first call, the indexer falls back to a fresh delta, re-walks the same message, dedupes viainternet_message_id, and persists the new token. Alsotest_is_delta_token_expired_classifierfor the 410 / 400-with-syncStateNotFoundclassifier.Per-mailbox
RunSummary{scanned, new, dedup_skipped, portal_required, parse_failed, delta_reset, delta_reset_reason, error}persisted tobank.indexer_state.last_summaryasjsonb. The CLI prints the aggregate to stdout (see Run 1 / 2 / 3 above).run_indexer()tool from Terminal backend fanout pr2 - PENDING NousResearch/hermes-agent#23, with identical behavior.main()builds the sameIndexerConfig+PostgresIndexerAdapter+GraphFetcherand callsrun(...). Terminal backend fanout pr2 - PENDING NousResearch/hermes-agent#23's wrapper would callfinance.indexer.run(adapter=…, fetcher=…)directly. One code path.Hard constraints honored
bank.transactions,bank.belege_sent,bank.belege_to_sendPostgresIndexerAdapteronlySELECTs from those three tables (dedupe lookups), noINSERT/UPDATE/DELETEagainst them anywhere in the codeanthropic/openaiinfinance/indexer.pyreturns nothingfinance.indexer.runnever importsfinance.matcherMicrosoftGraphClient._should_retryhandles 429 + 5xx; no custom retry loop in indexer.pymarker-pdfnot inrequirementsunconditionallyFINANCE_INDEXER_ALLOW_MARKERenv flag; import happens inside the conditional branch (extract_pdf_textbody)Migration cycle
Tests
48 prior (PRs #1/#2/#3) + 10 new indexer tests. Test file:
finance/tests/test_indexer.py.Coexistence with outlook_auto_rule
PostgresIndexerAdapter.belege_sent_has_messageshort-circuits the indexer when an inbound message'soutlook_message_idorinternet_message_idalready lives inbank.belege_sent. Verified bytest_coexistence_with_belege_sent_skips_indexing(no download triggered, no candidate row written). The 163 legacy server-side-forward rows therefore cannot produce a duplicate candidate.Backfill vs indexer row-shape asymmetry — intentional
Acknowledged in the spec and preserved here: PR #1's backfill produces one
receipt_candidatesrow per legacybelege_sentrow (historical, no SHA available). The live indexer produces one row per attachment, deduped byattachment_sha256. The two shapes coexist by design; no rewriting of the 71 backfilled rows.Spec note worth a moment of review (Graph auth)
The brief said to reuse
tools/microsoft_graph_client.py. The upstream client expects app-onlyclient_credentials(MSGRAPH_TENANT_ID/MSGRAPH_CLIENT_SECRET), but the only credentials available on-host are delegated (refresh-token grant againstLINEO_MS_CLIENT_ID, bundle at~/.hermes/lineo-ms-tokens/sebastian.json). PR #2 documented the same gap and solved it with a standaloneurllibscript — I went the other way:DelegatedTokenProvideris shape-compatible withMicrosoftGraphTokenProvider, so it plugs directly intoMicrosoftGraphClient(token_provider=…)and the indexer keeps the upstream pagination / streaming / retry/backoff machinery intact. Happy to migrate PR #2's script to the same provider in a follow-up.Configuration knobs
rechnung@lineo.finance,marketing@lineo.finance--mailbox(repeatable)["inbox"](well-known ID)--folder(repeatable)--since YYYY-MM-DDLocalBlobBackend(~/.hermes/finance/blobs)--blob-root <path>for local; programmatic viaBlobBackendProtocol for S3kundenservice.vodafone.com,vodafone.com,vodafone.de)IndexerConfig.portal_required_sendersFINANCE_INDEXER_ALLOW_MARKER=1~/.hermes/lineo-ms-tokens/sebastian.json--token-file <path>Cron entry
{ "id": "691b68f89b0a", "name": "Finance indexer (Mode-B receipt ingest)", "script": "finance_indexer.sh", "no_agent": true, "schedule": {"kind": "cron", "expr": "0 9-18 * * 1-5"}, "enabled_toolsets": ["terminal"], "deliver": "telegram", "workdir": "/home/hermes/work/hermes-agent" }Wrapper at
~/.hermes/scripts/finance_indexer.shloads~/.hermes/.env, runs the indexer with the project's venv Python (the only one withhttpx+pymupdf+psycopg2together), and is silent when there's no work to report so the channel doesn't get an hourly empty ping.Out of scope (intentionally not in this PR)
finance.parsers.parse(...))run_matcher()wrapper)Fixture-named PDFs in the live ingest
None of the fixture-named PDFs (Sipgate B4373121 / Notion ZWLWGPDN-0002 / Lucky Penny / Vodafone 122203440401) showed up in Run 1's 10-message window — they're outside the most-recent first-10. They'd land naturally as the cron runs over time, or when the 6-month default backfill kicks in on a fresh state. Per the brief, the goal is realistic ingest behaviour rather than fixture-driven completeness; not extending the window to force a match.
🤖 Generated with Claude Code