Import bank statements: CSV parsing, merchant rules, deduplication - #15
Merged
Conversation
lib/core/dates.dart holds the date-only helpers the statement importer needs, and replaces the identical private _isoDate copied into both the recurring and insights repositories. parseIsoDate round-trips its components rather than trusting DateTime, which silently rolls 2026-13-45 over into 2027-02-14. It returns null instead of throwing so a bad row in an imported statement can be reported without aborting the whole file. Also adds .github/workflows/test.yml: neither existing workflow ran analyze or test, so nothing enforced the suite. Kept separate from deploy-web.yml because it needs no secrets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
Pure logic only: no UI, no network, no I/O. Everything returns a result object
carrying validity and a human-readable status rather than throwing, matching
the SplitOutcome convention, so one bad row costs one row instead of the file.
Header handling maps columns by name through an alias table rather than by
position, because the same bank exports the same data under different headings
depending on the download screen (Date vs Transaction Date, Activity Status vs
Status). Supporting another export is one more alias, and a statement that
gains columns still parses.
Notes on the fiddly parts:
- shouldParseNumbers stays false. A 23-digit reference number exceeds int64
and would be silently corrupted as a double, breaking dedup.
- References are stored normalized. One export wraps them in quotes that
survive CSV parsing; a reference meaning two different strings depending
on its export is precisely what breaks cross-device dedup.
- The amount parser strips the sign before the currency symbol (these
exports write -$1000.00) and then requires a bare decimal, so '--5'
is rejected instead of having its sign applied twice.
- normalizeMerchant and suggestRulePattern are deliberately separate: the
first is non-destructive so a 'GOOGLE' rule still matches
'GOOGLE*Spotify Music'; the second strips store codes so a saved rule
covers every Costco rather than the one store the user was looking at.
Payments and refunds are dropped at parse time — expenses.amount has
check (amount > 0), so a credit could never become a shared expense.
Verified against three real statements (169 rows, two dialects, one with a
UTF-8 BOM and every field quoted): all parsed, zero issues, counts reconciling
exactly with the raw row counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
Rules decide two things for a matched merchant: which category tags it, and whether it is offered for sharing at all. They are group-scoped so everyone on a shared card tags the same merchant the same way rather than each roommate re-tagging it every month. Matching is substring-based on a normalized name because merchant names carry store and terminal noise — the same warehouse is COSTCO WHOLESALE W515 and W521, and processors prepend their own tag. Precedence is priority first, then longer pattern, so a narrow 'COSTCO GAS' rule beats a broad 'COSTCO' one without the user having to reason about priority numbers. The merchant category description is matched as a weaker second pass, for merchants whose names give nothing away. An unmatched row is still offered, just untagged. Silently hiding a transaction from someone reviewing a statement is worse than showing it. buildImportPlan runs every selected row through computeSplits — the same function both expense forms use — so there is one definition of what an equal or percentage split means. Only equal and percent are supported: an exact split is defined against a single total, so it cannot be applied across rows with different totals. Failures name the offending row and reuse the split rule's own wording, so the user reads "TOPDECK HERO: Total 90% (need 100%)" rather than meeting a Postgres error in a snackbar later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
Numbered 0011 rather than 0010 because 0010_group_photos.sql is a sibling on another branch; this keeps the two from colliding whichever order they land. merchant_rules stores merchant name patterns only — no amounts, dates, reference numbers or card numbers. Group-scoped with the usual is_group_member() policies. expenses.source_fingerprint is the only trace an imported statement leaves on the server: a client-computed SHA-256 of (group_id + normalized reference). One-way, and hashed on the client on purpose, since hashing it server-side would require sending the reference number — the exact thing this design avoids. The server therefore trusts the digest; the blast radius of a bad one is a group the caller already belongs to. The unique index is partial on two conditions. `source_fingerprint is not null` leaves hand-entered expenses unconstrained. `deleted_at is null` lets a mis-imported expense be deleted and imported again — without it the soft-deleted row would block its own transaction forever with no visible expense to explain why. Delete-then-reimport therefore does create a second expense, which is intended and documented rather than a bug. import_expenses is an RPC rather than client inserts for atomicity, for the server-side splits-sum-to-amount re-check, and because a duplicate needs to be *reported* rather than raised — a roommate having already imported the statement is a normal outcome. It returns counts plus the skipped fingerprints, so the client can stop offering them too. The `on conflict` clause repeats the partial index predicate verbatim, which Postgres requires to use it as an arbiter; a mismatch there fails at runtime rather than at migration time, so both carry a cross-reference comment. Not yet applied to the dev database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
ImportRepository.fetchImportedFingerprints is the load-bearing half of deduplication. The group's existing fingerprints are fetched rather than remembered on the device, because the case this feature exists for is two roommates importing the same shared-card statement from two phones: the second device has no local record of what the first one did, but it can see the group's hashes. It also means dedup works immediately on a new browser or a reinstalled app. The expenses SELECT policy already filters deleted_at is null, so a deleted expense drops out of that set and its transaction becomes importable again — matching the partial unique index rather than fighting it. A skipped row is modelled as a normal result, not an exception: ImportResult carries inserted/skipped counts and the skipped fingerprints so the client can stop offering them. Merchant rule patterns are stored uppercased so matching never has to case-fold the stored side, and so the unique (group_id, pattern, match_type) constraint actually catches a re-added duplicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
The import screen is one Scaffold with a pick/review/done step enum rather than a wizard: the flow is short and the state is shared across the steps. The parsed statement lives in screen state and nowhere else — not uploaded, not written to disk. Closing the screen discards it. The pick step says so in as many words, and a widget test pins that copy, since the promise is the whole design. Review reuses what already exists rather than inventing: Card + CheckboxListTile rows like the expense list, the shared iconForCategory, and computeSplits via buildImportPlan. No DataTable — the app has none, a table has no mobile story, and horizontal scroll would fight the 1100px content column. The split selector is a local two-segment control rather than the shared SplitTypeSelector, whose third 'exact' segment is meaningless applied across rows with different totals. Already-imported rows render with a disabled checkbox and an "Already imported" chip. That is deduplication's visible face, and it covers rows imported by someone else on another device, because the fingerprints are fetched from the group rather than remembered locally. "Always tag this merchant" writes a group-wide rule prefilled from suggestRulePattern, so the saved rule covers every Costco rather than the one store the user was looking at, and re-runs matching immediately without discarding rows the user hand-tagged. Entry point is an app-bar action, not a fourth tab: a tab would change TabController(length: 3) and the smoke test that pins it. That test now also asserts the action exists. Verified: flutter analyze clean, 166 tests pass, and flutter build web --release succeeds with file_picker, so the Cloudflare deploy path is intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
Writes down the parts that are non-obvious six months from now: why the unique index is partial on two conditions (and that delete-then-reimport creating a second expense is intended, not a bug), why the on-conflict predicate must match it verbatim or fail at runtime, and the threat model for the fingerprint — including the fact that the server structurally cannot verify it. Also records why normalizeMerchant and suggestRulePattern must stay separate, and the one-line seam for adding a PDF parser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc
LouisBenjamin
added a commit
that referenced
this pull request
Sep 2, 2026
The README was last expanded in #14, before the statement import feature landed in #15, so it had no mention of the importer at all. - New "Importing a bank statement" feature section - New "Decision: statement import keeps raw data off the server" section covering the privacy boundary, the client-side fingerprint, and the partial unique index - merchant_rules and expenses.source_fingerprint in the data model table - import_expenses() in the RPC list - computeSplits() now noted as shared by three call sites, not two - features/import/, core/dates.dart, docs/statement-import.md in the tree - test count 58 -> 165, plus the import test group - test.yml documented; dropped the stale "tests in CI" gap and added "PDF statement import" to the roadmap - removed no em dashes (there were none); kept the existing voice Claude-Session: https://claude.ai/code/session_01PcEjJSFabfyzty3uUXx6dQ Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.
Import a bank CSV, auto-tag the transactions against group-wide merchant rules, and promote the shared ones into expenses. Built for the case where roommates share one card and want to split the groceries off it.
The privacy boundary
Raw statement data never reaches the server. The CSV is parsed on-device and held in screen state for the review session only — never uploaded, never written to disk. Closing the screen discards it.
Exactly two things cross the wire:
merchant_rulesexpenses.source_fingerprintgroup_id + ':' + normalized referenceThe fingerprint is hashed client-side on purpose: hashing it server-side would require sending the reference number, which is the exact thing this design avoids. The server therefore can't verify it and simply trusts it — blast radius is a group the caller already belongs to.
Deduplication
Re-importing an overlapping statement never double-charges, including from a second device — which is the actual roommate scenario.
The group's existing fingerprints are fetched from Supabase rather than remembered locally. That's the key decision: your roommate's phone has no local record of what your phone imported, but it can read the group's hashes. It also means dedup works instantly on a new browser or reinstall.
A partial unique index backstops the simultaneous-import race:
deleted_at is nullmeans deleting a mis-imported expense lets you import it again. Without it, a soft-deleted row would block its own transaction forever with no visible expense to explain why. The cost — delete-then-reimport creates a second expense — is the intended trade, and is documented rather than left to surprise someone.Notable decisions
drift; dropped once it was clear dedup works better without it, avoidingbuild_runnercodegen in a zero-codegen repo andsqlite3.wasminweb/.DataTable. The app has none, a table has no mobile story, and horizontal scroll would fight the 1100px content column. Rows areCard+CheckboxListTile, like the expense list.TabController(length: 3)and the smoke test that pins it.computeSplitsis reused per row, so there's still exactly one definition of what a split means.normalizeMerchantandsuggestRulePatternstay separate: the first is non-destructive so aGOOGLErule still matchesGOOGLE*Spotify Music; the second strips store codes so a saved rule covers every Costco rather than the one branch you were looking at.Also included
lib/core/dates.dart, de-duplicating the identical private_isoDatefrom two repositories..github/workflows/test.yml— neither existing workflow rananalyzeortest, so nothing enforced the suite.Verification
flutter analyzeclean; 165 tests pass (102 new)flutter build web --releasesucceeds with bothfile_pickerandimage_pickerset local role authenticated+ JWT impersonation:{"inserted": 0, "skipped": 1}(proves the partial-index arbiter binds)inserted: 1merchant_rulesRLS round-tripsNot in scope
PDF import is phase 2. The seam exists — add a parser to
kStatementParsersand nothing else changes. It must stay client-side, which rules out native-only libraries likepdf_text.See
docs/statement-import.mdfor the threat model and the parser quirks.🤖 Generated with Claude Code
https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc