Skip to content

feat(csv-import): drop-anywhere import — dedupe, account detection, saved profiles#1786

Open
canalesb93 wants to merge 7 commits into
mainfrom
worktree-csv-import-v2
Open

feat(csv-import): drop-anywhere import — dedupe, account detection, saved profiles#1786
canalesb93 wants to merge 7 commits into
mainfrom
worktree-csv-import-v2

Conversation

@canalesb93

Copy link
Copy Markdown
Owner

CSV import v2 — drop-anywhere, dedupe, account detection, saved profiles

Turns CSV import from a nice-to-have wizard into a first-class, confidence-inspiring path. Drop a CSV on any page → a global hover overlay → a polished flow that detects which account the file belongs to, dedupes against what's already there (including Plaid/Teller rows), shows a preview of exactly what will change, lets you make targeted edits, and applies only on confirm. Re-dropping a later export of the same account only adds the genuinely new rows.

CSV import flow — design sandbox

How it works

  • Drop anywhere — a body-level drag overlay (file-drags only, [data-dropzone] opt-out) opens a full-screen daisy modal: analyze → ranked account picker → deduped preview → apply.
  • Account detection — a deterministic, explainable weighted scorer: saved-profile default account (auto), mask/last-4 (+40), transaction overlap (≤+40), institution name (+15), filename hint (+5). Top account is pre-selected only when it's strong and a clear leader; otherwise you pick or create one inline.
  • Dedup — each row is classified against the resolved account's live transactions as new / exact-dup (stable provider_transaction_id or account-independent content_hash) / probable-dup (same amount, date ±1d, similar name via the shared sync-matcher similarity) / error. Apply upserts via UpsertTransactionV2 and is idempotent — re-importing the same file is a no-op.
  • Saved profiles — keyed by a header fingerprint, auto-saved on apply (remembering the mapping + account). A matching re-drop auto-applies the recipe and pre-selects the account, going straight to a deduped preview. Manage via /-/csv/v2/profiles.
  • Currency — detected from the file, defaulting to the target account's, stored without conversion (respects the never-sum-across-currency invariant).

Architecture

Durable staging (csv_import_sessions + csv_import_rows) so the applied result equals the preview. All additive migrations (shared-DB safe). Admin-only routes under /-/csv/v2/* (no OpenAPI surface). The legacy wizard at /admin/connections/import-csv is left intact in parallel.

Built in 6 self-contained waves (each independently builds + vets + tests green):

  1. textmatch extraction, staging schema, content_hash, fingerprint helpers
  2. dedup classifier + account matcher (service)
  3. session lifecycle + atomic idempotent apply
  4. admin HTTP endpoints
  5. global drag-drop + full-screen flow modal (daisy-first, sandboxed in /design/c/csv-import)
  6. TTL sweeper, profile management, docs

Testing

  • Unit: internal/textmatch, internal/provider/csv (fingerprint/mask).
  • Integration: dedup splits (incl. cross-provider), overlap/mask/profile account ranking, full lifecycle, profile-driven re-drop (dedup 2 + add 1), double-import idempotency, account-change-before-confirm, profile list/rename/delete, and an HTTP smoke test through the real router.
  • go build ./... (default + -tags lite + -tags headless), go vet ./..., and the service/admin/db/api integration suites all pass.

Docs: docs/csv-import.md.

🤖 Generated with Claude Code

canalesb93 and others added 7 commits June 6, 2026 21:40
…tent_hash, fingerprint

Groundwork for CSV import v2 (drag-anywhere, dedupe, account detection, profiles):

- Extract pure name-similarity scorer into tag-free internal/textmatch so the
  CSV classifier can reuse the exact sync-matcher logic without importing sync
  (cycle-free). Matcher delegates; behavior unchanged (existing tests pass).
- Migrations (additive, shared-DB safe):
  - transactions.content_hash (account-independent dedup) + partial
    transactions_dedup_lookup index on (account_id, date, amount).
  - csv_import_profiles (saved recipes keyed by header fingerprint).
  - csv_import_sessions + csv_import_rows (durable staging so preview == apply).
- sqlc queries for the three tables (rows via :copyfrom for 50k-row inserts)
  plus UpsertTransactionV2 returning (xmax = 0) AS inserted for reliable
  insert-vs-update counts.
- internal/provider/csv/fingerprint.go: HeaderFingerprint, ExtractMask
  (card column + filename), FilenameTokens, InstitutionTokens. Unit tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atcher

- internal/service/csv_classify.go: ParseCSVRows (mapping → parsed rows, errors
  preserved for inline-fix) + ClassifyCSVRows. Loads the resolved account's live
  transactions in the date window (provider-agnostic — dedupes vs Plaid/Teller
  too) and classifies each row as new / exact_dup (provider id or content_hash) /
  probable_dup (same amount, date within tolerance, name similar via textmatch) /
  error. content_hash + stable provider_txn_id derived per row.
- internal/service/csv_account_match.go: weighted, explainable account ranker —
  saved-profile default account (auto), mask/last4 (+40), transaction overlap
  (≤+40), institution name (+15), filename hint (+5). Preselect only when the
  top is strong AND a clear leader, or a profile matched.
- csv dedup helpers: GenerateContentHash (account-independent) +
  GenerateExternalIDWithOccurrence (force-import disambiguator).
- sqlc: ListAccountTransactionsForDedup, ListTransactionKeysInRange.
- Integration tests: all-new, exact dup by provider id, cross-provider probable
  dup, nearby-date/different-name stays new, parse error; overlap ranking,
  mask+overlap preselect, profile preselect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
internal/service/csv_import_v2.go — the durable staging flow:

- CreateImportSession: parse file, detect template/mapping/date-format/currency,
  apply a saved profile (auto mapping + auto-resolve to its account), run account
  detection, persist the session + rows. Auto-classifies when an account is
  confidently chosen; otherwise stages rows as needs_account.
- ResolveImportAccount: bind an existing account or mint a new CSV connection+
  account, reclassify every row, status → previewed.
- ListImportRows: server-paginated rows + per-classification summary counts.
- EditImportRow: inline-edit a row and reclassify just it.
- BulkImportOp: include/exclude by classification, set-category, remap (re-parse
  + reclassify all).
- ApplyImportSession: one tx — upsert the exact included set via
  UpsertTransactionV2 (reliable insert/update counts), occurrence-disambiguate
  genuine same-key duplicates, stamp session currency, write a sync_log, upsert
  the source profile, finalize (snapshot result, drop blob). Idempotent: a
  re-drop into the same account upserts to no-ops.

New row column category_id (migration) for user-chosen Breadbox category applied
at import; CountIncludedCSVImportRows query.

Integration tests: full lifecycle, profile-driven re-drop (dedup 2 + add 1),
double-import idempotency, account-change-before-confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
internal/admin/csv_import_v2.go + routes under /-/csv/v2/* (session-cookie auth,
admin-only, so no OpenAPI surface / drift impact):

- POST   /-/csv/v2/sessions                 multipart analyze → ImportAnalysis
- POST   /-/csv/v2/sessions/{id}/resolve    bind/create account → session
- GET    /-/csv/v2/sessions/{id}/rows       paginated rows + summary + session
- PATCH  /-/csv/v2/sessions/{id}/rows/{rid} inline edit
- POST   /-/csv/v2/sessions/{id}/bulk       include/exclude/set-category/remap
- POST   /-/csv/v2/sessions/{id}/apply      apply → CSVImportResult

30MB upload cap; single-household-member default user; error envelope mapping
(404 for unknown session, 422 otherwise).

Integration test drives analyze → resolve(create-new) → rows → apply through a
real chi router and asserts 2 new transactions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…odal

UI for the v2 flow, daisy-first, matching the Workflows-page quality bar:

- base.html: $store.csvImport store + document-level drag-and-drop (dragenter
  depth counter, file-only overlay, [data-dropzone] opt-out, multi-file → first
  + toast). Full-window dashed drop overlay. Mounts components.ImportModal in the
  shared global-overlay region (renders on every page, hidden until a drop).
- components.ImportModal: large daisy <dialog class="modal"> flow —
  analyzing / account picker (ranked suggestions w/ confidence + reason chips +
  saved-profile badge + create-new) / preview (classification filter join-tabs,
  summary counts, server-paginated table table-sm with include checkboxes,
  classification badges, inline row edit, bulk include/exclude) / success.
- static/js/admin/components/csv_import_v2.js: the csvImport Alpine factory —
  watches the store, drives analyze→resolve→rows→edit/bulk→apply against the
  /-/csv/v2/* endpoints (CSRF auto-injected), toasts on apply.
- Registered ImportModal in the renderComponent bridge.
- Sandboxified: SectionCSVImport in /design (patterns) — drop overlay, account
  suggestions, classification badges, preview table, success. Screenshot-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SweepExpiredImportSessions + hourly cron in serve.go: reaps un-applied
  sessions past their 24h TTL (CASCADE drops staged rows + the raw file blob).
- Profile management: ListCSVProfiles / RenameCSVProfile / DeleteCSVProfile
  (internal/service/csv_profiles.go) + admin endpoints GET/PATCH/DELETE
  /-/csv/v2/profiles[/{id}]. A user's rename survives future imports; times_used
  bumps on re-import. Integration test covers list/rename/redrop/delete.
- docs/csv-import.md: canonical doc for the v2 flow (staging, account detection,
  dedup, profiles, currency, schema).
- Makefile sqlc/sqlc-tag now clean + build-tag internal/db/copyfrom.go (new
  :copyfrom artifact) so the lite build excludes the db package correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Driven by two real exports (Chase checking, Amex):

- ParseFile sets FieldsPerRecord = -1. Chase checking CSVs append a trailing
  comma to every data row (8 fields vs a 7-column header); Go's csv.Reader
  rejected the whole file. Column mapping is index-based + bounds-checked, so
  ragged trailing fields are harmless. (Amex's embedded newlines in quoted
  fields already parsed correctly.)
- ExtractMask now also recognizes the "<institution><last4>" filename shape
  (Chase0198, Amex1009) — letters immediately followed by 4 digits — without
  mistaking a date stamp (export_20260607) for a mask.

Regression tests: ragged-row parse + Chase Checking template match; the new
filename mask cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <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