Skip to content

Import bank statements: CSV parsing, merchant rules, deduplication - #15

Merged
LouisBenjamin merged 7 commits into
mainfrom
feature/statement-import
Sep 2, 2026
Merged

Import bank statements: CSV parsing, merchant rules, deduplication#15
LouisBenjamin merged 7 commits into
mainfrom
feature/statement-import

Conversation

@LouisBenjamin

Copy link
Copy Markdown
Owner

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:

What it holds
merchant_rules Merchant name patterns only — no amounts, dates, references, card numbers
expenses.source_fingerprint An opaque SHA-256 of group_id + ':' + normalized reference

The 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:

create unique index expenses_source_fingerprint_key
  on expenses (group_id, source_fingerprint)
  where source_fingerprint is not null and deleted_at is null;

deleted_at is null means 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.

⚠️ The on conflict clause repeats that predicate verbatim. Postgres won't use a partial unique index as an arbiter otherwise, and the mismatch fails at runtime, not migration time.

Notable decisions

  • No local database. An earlier draft used drift; dropped once it was clear dedup works better without it, avoiding build_runner codegen in a zero-codegen repo and sqlite3.wasm in web/.
  • No DataTable. The app has none, a table has no mobile story, and horizontal scroll would fight the 1100px content column. Rows are Card + CheckboxListTile, like the expense list.
  • Import is an app-bar action, not a fourth tab — a tab would change TabController(length: 3) and the smoke test that pins it.
  • computeSplits is reused per row, so there's still exactly one definition of what a split means.
  • normalizeMerchant and suggestRulePattern stay 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 branch you were looking at.
  • Regex rules deliberately unsupported — one roommate's bad pattern would break everyone's import.

Also included

  • lib/core/dates.dart, de-duplicating the identical private _isoDate from two repositories.
  • .github/workflows/test.ymlneither existing workflow ran analyze or test, so nothing enforced the suite.

Verification

  • flutter analyze clean; 165 tests pass (102 new)
  • flutter build web --release succeeds with both file_picker and image_picker
  • Parsed against three real statements — 169 rows, two header dialects (one BOM'd and fully quoted), zero issues, counts reconciling exactly
  • Migration 0011 applied and verified on the dev DB under set local role authenticated + JWT impersonation:
    • same fingerprint twice → {"inserted": 0, "skipped": 1} (proves the partial-index arbiter binds)
    • soft-delete → reimport → inserted: 1
    • sum / credit / non-member guards all raise readable sentences, not Postgres internals
    • merchant_rules RLS round-trips

Not in scope

PDF import is phase 2. The seam exists — add a parser to kStatementParsers and nothing else changes. It must stay client-side, which rules out native-only libraries like pdf_text.

See docs/statement-import.md for the threat model and the parser quirks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BK6T2aAySsWFu27SUq6bTc

LouisBenjamin and others added 7 commits September 1, 2026 20:22
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
LouisBenjamin merged commit 89f8693 into main Sep 2, 2026
2 checks passed
@LouisBenjamin
LouisBenjamin deleted the feature/statement-import branch September 2, 2026 00:29
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>
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