···11+## Context
22+33+Quantum's ingestion spine is: fetch bytes → archive them verbatim in `raw_syncs`
44+→ normalize as a pure, replayable function of the archived payload → upsert. That
55+shape is load-bearing and this change preserves it.
66+77+The relevant current state:
88+99+- `transactions` is keyed `UNIQUE (account_id, sfin_id)` with `sfin_id TEXT NOT
1010+ NULL`. Identity comes from the provider; nothing in the codebase synthesizes it.
1111+- `ingestTransactions` (`src/lib/server/services/sync.ts:183`) is an
1212+ **authoritative** writer. It assumes its input is the complete truth for the
1313+ account over a window: it reconciles new posted rows against pending rows within
1414+ ±5 days, and it soft-removes every pending row absent from the feed
1515+ (`sync.ts:291-301`).
1616+- `accounts.connection_id` is `NOT NULL`, and `account-management` requires that
1717+ accounts originate only from sync.
1818+- `normalizePayload` (`normalize.ts:61`) is pure and emits `NormalizedAccount[]`.
1919+- `applyRulesToUncategorized` (`rules.ts:168`) is unscoped and database-wide.
2020+- Reports scope on `t.pending = 0 AND t.removed_at IS NULL` (`reports.ts:7`) and
2121+ order by `COALESCE(posted, transacted_at, created_at)`.
2222+2323+The user-facing problem is one account whose SimpleFIN feed reaches back only
2424+about a week. Critically, its synced history still **accumulates**: each daily sync
2525+tops up the last seven days, so over time the account holds synced rows across a
2626+widening stretch. A backfill CSV and the synced data therefore overlap across a
2727+*region*, not at a boundary line.
2828+2929+## Goals / Non-Goals
3030+3131+**Goals:**
3232+3333+- Close a historical gap in one existing account from a bank's CSV export.
3434+- Never corrupt synced data. An import must not be able to delete, reconcile, or
3535+ overwrite anything sync owns.
3636+- Re-importing the same or an overlapping file must be a no-op, not a duplication.
3737+- Surface plausible cross-source duplicates for human judgment before commit.
3838+- Make a botched import reversible as a unit.
3939+- Preserve the archive-then-replay property for CSV payloads.
4040+4141+**Non-Goals:**
4242+4343+- Creating accounts from a CSV. `account-management` forbids it, and a
4444+ connection-less account would have no balance snapshots, which would silently
4545+ break the net-worth report. Out of scope, deliberately.
4646+- Importing a category column. Provenance for "Mint thought so in 2024" is an
4747+ unresolved question and `categorization_events.source` is a closed enum. Rules
4848+ cover the need for now.
4949+- Saved per-account mapping profiles. Worth it only if imports recur; this is a
5050+ one-time gap fill.
5151+- Fuzzy merchant matching. Duplicate detection stays on amount and date.
5252+5353+## Decisions
5454+5555+### 1. CSV is an additive writer, not a reuse of `ingestTransactions`
5656+5757+**Decision:** A new, insert-only ingestion path. It never reconciles, never
5858+sweeps, never updates an existing row.
5959+6060+**Why:** Reusing `ingestTransactions` looks attractive — it's the same table and
6161+the same `NormalizedTransaction` shape — but it would be a live bug. Its stale
6262+sweep soft-deletes every pending row in the account not present in the feed; a
6363+2023 backfill contains none of today's pending rows, so importing would silently
6464+remove all of them. Its ±5-day reconciliation could also let an old CSV row hijack
6565+a live pending row.
6666+6767+The seam is one level below the function: sync and CSV share the table and the
6868+insert, but not the authority. Sync is authoritative for a window; CSV is additive
6969+into a gap. Encoding that distinction as two writers is both safer and less code
7070+than parameterizing one writer with a `mode` flag, which would leave the dangerous
7171+paths one boolean away from a backfill.
7272+7373+**Alternative considered:** Add `authoritative: boolean` to `ingestTransactions`.
7474+Rejected — the flag makes the hazardous branches reachable from the CSV path by
7575+mistake, and the shared code would be only the INSERT statement.
7676+7777+### 2. Synthetic identity: content hash + occurrence index
7878+7979+**Decision:** `sfin_id = 'csv:' + hash(account_id, date, amount_cents,
8080+description, occurrence_index)`, where `occurrence_index` is the row's ordinal
8181+among identical rows within the same file.
8282+8383+**Why:** `sfin_id` is `NOT NULL` under a unique key, so CSV must invent one. A
8484+plain content hash would collapse two genuinely distinct identical transactions
8585+(two $4.75 coffees on one Tuesday) into a single row. The occurrence index keeps
8686+them distinct while staying deterministic.
8787+8888+This yields idempotency with a pleasing property: because the grouping key *is*
8989+the row's content, rows within a group are interchangeable. If a second export
9090+lists the same group in a different order, or contains a superset of it, the set
9191+of derived ids is unchanged for the rows that already exist, and only genuinely
9292+new rows insert. Re-import of an identical or overlapping file is therefore a
9393+no-op that never even reaches duplicate review.
9494+9595+**Alternative considered:** Rename `sfin_id` to `source_id` and add a `source`
9696+discriminator. Honest, but it touches every query in the codebase, and `import_id`
9797+(decision 5) already records true origin. Namespacing the value with a `csv:`
9898+prefix keeps the lie small and greppable.
9999+100100+### 3. Archive on upload, decide later
101101+102102+**Decision:** The verbatim file is written to a new `imports` row at upload time
103103+with `status = 'draft'`, before any parsing. The column mapping and the duplicate
104104+decisions are stored on that same row. Wizard steps re-parse the archived bytes.
105105+106106+**Why:** This does double duty. It honors the existing "archive before
107107+normalization" spine, and it solves where a multi-step wizard keeps its state
108108+without stuffing a CSV into a session. Replay determinism requires the mapping and
109109+the decisions to be archived alongside the bytes — bytes alone do not determine
110110+the outcome, since a human chose the mapping and adjudicated the duplicates.
111111+112112+**Alternative considered:** A separate `raw_imports` (bytes) plus `imports`
113113+(record). Rejected as over-normalization for a two-person app; the payload column
114114+inside `imports` is written once and never mutated, which preserves the property
115115+that matters.
116116+117117+### 4. Duplicate detection: amount and date, never description
118118+119119+**Decision:** A CSV row is flagged when the target account already holds a
120120+non-removed transaction with the identical `amount_cents` and an effective date
121121+within ±1 day. Descriptions are displayed side by side but never matched on.
122122+Flagged rows default to **skip**.
123123+124124+**Why:** The two sources render the same merchant differently — SimpleFIN gives
125125+the bank's raw string (`AMZN Mktp US*2K4LM9QR3`), the CSV gives its own rendering
126126+(`Amazon`). Matching on description would catch almost nothing, which is the worst
127127+outcome: a duplicate check that appears to run and silently passes everything. So
128128+description is evidence for the human, not a filter.
129129+130130+Amount + date alone is deliberately loose and will throw occasional false
131131+positives (a second identical coffee). That is what the review step is for; a
132132+loose matcher plus a human beats a tight matcher that misses.
133133+134134+The ±1 day window rather than same-day: a CSV's single date column is often the
135135+transaction date while SimpleFIN's `posted` can trail it, so strict same-day would
136136+miss the very rows most at risk.
137137+138138+**Skip as the default is principled, not arbitrary.** Where both sources claim a
139139+transaction, the synced row is strictly better: it carries a real SimpleFIN id so
140140+it stays idempotent under future syncs, it holds the bank's own description, and
141141+it already carries categorization history. The CSV row is a photocopy of a record
142142+already held. Declining it loses nothing. The human's job in the wizard is to
143143+catch the false positives and flip those few to keep — a much lighter chore than
144144+adjudicating every row from scratch.
145145+146146+### 5. `import_id` on transactions: undo and origin in one column
147147+148148+**Decision:** `transactions.import_id INTEGER NULL REFERENCES imports (id)`.
149149+NULL means synced.
150150+151151+**Why:** One nullable column serves three needs. Undo becomes "soft-remove where
152152+`import_id = ?`". The ledger's source filter becomes `import_id IS NULL` /
153153+`IS NOT NULL`. The origin line in the history panel joins through it. Retrofitting
154154+this later would mean re-deriving hashes to work out which rows came from which
155155+file.
156156+157157+Undo **soft-removes** (`removed_at = now`) rather than hard-deleting, matching the
158158+existing stale-pending precedent and the house rule that history is never deleted.
159159+Hard deletion would also collide with the append-only categorization event log,
160160+since events reference `transaction_id`.
161161+162162+Undo sets `imports.status = 'undone'`. Re-importing a corrected file after an undo
163163+generally produces different hashes (a fixed mapping parses different values), so
164164+new rows insert cleanly. In the case where the same mapping is re-imported, the
165165+insert path finds the soft-removed row by unique key and revives it
166166+(`removed_at = NULL`, new `import_id`) rather than erroring.
167167+168168+### 6. Which date column to write
169169+170170+**Decision:** Map the CSV's date to `posted`, and set `pending = 0`. If the file
171171+exposes a distinct transaction date, map it to `transacted_at`; otherwise leave it
172172+NULL.
173173+174174+**Why:** Reports scope on `pending = 0` (`reports.ts:7`), not on `posted IS NOT
175175+NULL`, so either choice lands in reports. But a row with `pending = 0` and `posted
176176+IS NULL` is a state sync has never produced, and inventing novel states for
177177+downstream queries to encounter is the riskier path. A CSV export is by definition
178178+a statement of settled history, so `pending = 0` with `posted` set matches the
179179+shape sync emits for posted rows, and every existing query behaves identically.
180180+181181+The residual imprecision — the file's single date column may be the transaction
182182+date rather than the post date — is absorbed by the ±1 day duplicate window.
183183+184184+### 7. Amount parsing: a CSV pre-pass, not a change to `parseAmountToCents`
185185+186186+**Decision:** Normalize CSV negative conventions (`(12.34)`, `12.34-`, currency
187187+symbols) to a signed decimal string, then hand off to the existing
188188+`parseAmountToCents`. Support a single signed column, and separate debit/credit
189189+columns, as distinct mapping modes.
190190+191191+**Why:** `parseAmountToCents` (`normalize.ts:48`) is carefully float-free and its
192192+regex `^([+-]?)(\d*)(?:\.(\d+))?$` is a deliberate contract for SimpleFIN's
193193+numeric strings. Loosening it to accept accountant's parentheses would weaken
194194+validation on the sync path to serve the CSV path. A pre-pass keeps each source's
195195+tolerances where they belong.
196196+197197+### 8. Categorization comes free
198198+199199+**Decision:** Call `applyRulesToUncategorized(db)` once after commit.
200200+201201+**Why:** It's already unscoped and database-wide (`rules.ts:168`), and it already
202202+skips transactions whose latest event is `manual`. Backfilled history categorizes
203203+itself against existing rules with no new code and no new event source.
204204+205205+### 9. Surfacing: filter and popover, no ledger badge
206206+207207+**Decision:** `LedgerFilters` gains `source?: 'synced' | 'imported'`; `LedgerRow`
208208+gains origin; the existing history panel gains an origin line. No per-row badge.
209209+The import record lives in Settings, not the main nav.
210210+211211+**Why:** Within a backfilled range every row is imported and above it none are, so
212212+a per-row badge would be present on 100% of rows where it appears — carrying no
213213+information exactly where it's densest, while competing with the existing
214214+categorization badge, which answers a different question (*who chose this
215215+category*, not *where did this row come from*).
216216+217217+A seam marker at the import boundary was considered and rejected on stronger
218218+grounds: there is no boundary. Because the short-history account accumulates
219219+synced rows daily, synced and imported rows genuinely interleave across the same
220220+dates. A seam would be a drawn line where none exists.
221221+222222+Origin interest is episodic — it matters during and shortly after an import, then
223223+it is just history. A filter answers it on demand; the popover answers it for a
224224+specific row where the question is actually asked.
225225+226226+## Risks / Trade-offs
227227+228228+- **A wrong column mapping silently imports garbage** (e.g. dates off by a
229229+ century, amounts inverted) → Undo via `import_id`, plus a preview step that
230230+ states the parsed date range and row count before commit, where an inverted or
231231+ misparsed column is obvious at a glance.
232232+233233+- **The overlap region is wide, so duplicate review could have real volume** →
234234+ Skip-by-default means clicking through without reading yields the safe outcome
235235+ (synced data retained, no double-counting). The cost of inattention is a missing
236236+ photocopy, not corrupted reporting.
237237+238238+- **A false positive silently drops a real transaction** (two genuinely distinct
239239+ identical charges on one day, one already synced) → This is the flip side of
240240+ skip-by-default and is the accepted trade. The wizard shows both rows side by
241241+ side specifically to make it catchable, and the import record retains the raw
242242+ file, so a missed row can be recovered by re-importing with a corrected
243243+ decision.
244244+245245+- **`sfin_id` now holds values that are not SimpleFIN ids** → Namespaced `csv:`
246246+ prefix makes them greppable, and `import_id` is the authoritative origin field.
247247+ Accepted as a small, contained dishonesty rather than a codebase-wide rename.
248248+249249+- **Abandoned drafts accumulate** → Low volume by nature; drafts are invisible to
250250+ every surface except the import record. Not worth a reaper.
251251+252252+- **Two writers to `transactions` could drift apart** → The CSV writer is
253253+ insert-only and deliberately shares no code with `ingestTransactions` beyond the
254254+ row shape. The risk is a future schema change updating one and not the other;
255255+ mitigated by both paths being covered by tests over the same table.
256256+257257+## Migration Plan
258258+259259+One additive migration (`005_csv_import.sql`):
260260+261261+- `CREATE TABLE imports` — `id`, `account_id` (FK, NOT NULL), `filename`,
262262+ `uploaded_at`, `payload` (verbatim, never mutated), `mapping` (JSON), `decisions`
263263+ (JSON), `status` CHECK in (`draft`, `committed`, `undone`), `committed_at`,
264264+ `undone_at`.
265265+- `ALTER TABLE transactions ADD COLUMN import_id INTEGER REFERENCES imports (id)`
266266+ — nullable, NULL meaning synced, so every existing row is correct without a
267267+ backfill.
268268+- Index on `transactions (import_id)` for undo and the source filter.
269269+270270+No changes to existing columns and no data rewrite, so the migration is
271271+forward-only and safe to re-run against a populated database. Rollback is dropping
272272+the table and column; imported rows would need removal first, which the undo flow
273273+already performs.
274274+275275+## Open Questions
276276+277277+- Should the preview step hard-refuse a file whose parsed range extends into the
278278+ future or predates the account's opening, as a cheap guard against a
279279+ catastrophically wrong date mapping — or is stating the range enough?
280280+- Does the import record in Settings need to list the individual skipped rows, or
281281+ is a count sufficient?
···11+## Why
22+33+One connected account only returns about a week of transaction history from
44+SimpleFIN, while another returns the full 90 days. The short-history account
55+therefore has a permanent hole in its past that no sync will ever fill — the
66+lookback window advances, it does not reach back. Reports that span more than a
77+week are silently wrong for that account, and no amount of waiting fixes it.
88+99+The bank does publish that history as a CSV export. This change lets a user pour
1010+that file into the account it belongs to, once, to close the gap.
1111+1212+## What Changes
1313+1414+- A CSV import flow, reached from Settings: upload a file, attribute it to one
1515+ existing account, confirm the column mapping, review potential duplicates,
1616+ commit.
1717+- Imports are **additive only**. A CSV never reconciles pending transactions,
1818+ never marks accounts inactive, never removes rows, and never overwrites a
1919+ synced transaction. It inserts, or it does nothing.
2020+- Imported transactions get a **synthetic stable id** derived from their content,
2121+ so re-importing the same file (or an overlapping range from a second file) is
2222+ a no-op rather than a duplication.
2323+- **Duplicate review**: rows matching an existing transaction in the same account
2424+ by amount and near-identical date are surfaced for human judgment before
2525+ commit, defaulting to skip. Descriptions are shown side by side to inform the
2626+ decision but are not used to match, because the two sources render the same
2727+ merchant differently.
2828+- **Undo**: every imported transaction records which import produced it, so a
2929+ botched mapping can be reversed as a unit.
3030+- Raw uploaded bytes are archived verbatim before any normalization, alongside
3131+ the mapping and duplicate decisions, preserving the existing "normalization is
3232+ a replayable pure function of an archived payload" property.
3333+- The ledger gains a **source filter** (synced / imported), and a transaction's
3434+ origin appears in its existing history panel. No new per-row ledger chrome:
3535+ within a backfilled range every row is imported, so a per-row badge would carry
3636+ no information where it appeared most.
3737+- Rules run over imported transactions after commit, so backfilled history
3838+ categorizes itself against rules that already exist.
3939+4040+Not in this change: creating accounts from a CSV, importing a category column
4141+from a CSV, and saved per-account mapping profiles. See Impact.
4242+4343+## Capabilities
4444+4545+### New Capabilities
4646+- `csv-import`: Uploading a CSV of historical transactions, archiving it
4747+ verbatim, mapping its columns, detecting and adjudicating potential duplicates
4848+ against existing data, additively ingesting the survivors into one existing
4949+ account, recording the import as a reversible unit, and undoing it.
5050+5151+### Modified Capabilities
5252+- `reporting`: The transaction ledger requirement gains a source filter (synced
5353+ vs. imported) that composes with the existing filters, and the ledger surfaces
5454+ a transaction's origin in its detail/history panel.
5555+5656+## Impact
5757+5858+**Affected specs**: new `csv-import`; modified `reporting` (transaction ledger
5959+requirement).
6060+6161+**Deliberately unaffected**: `account-management` requires that "the system SHALL
6262+create account records only from sync data, never via in-app creation." This
6363+change honors that rule rather than repealing it — a CSV must be attributed to an
6464+account that sync already discovered. Consequently no CSV-only account exists, no
6565+balance snapshots are invented, and the net-worth report is untouched.
6666+`simplefin-sync` is likewise unchanged; its reconciliation and stale-sweep
6767+behavior remain the exclusive authority of sync.
6868+6969+**Affected code**:
7070+- `migrations/` — new migration: a `raw_imports` archive table; a nullable
7171+ `import_id` FK on `transactions`.
7272+- `src/lib/server/services/sync.ts` — `ingestTransactions` is *not* reused. It
7373+ treats its input as authoritative for the account (its stale-pending sweep would
7474+ soft-delete every pending row absent from a backfill CSV, and its ±5-day
7575+ reconciliation could let an old CSV row hijack a live pending row). CSV needs a
7676+ separate, insert-only writer.
7777+- `src/lib/server/services/normalize.ts` — `parseAmountToCents` is reusable but
7878+ its regex rejects the parenthesized-negative and trailing-minus conventions
7979+ common in bank CSV exports; it needs a CSV-flavored pre-pass rather than a
8080+ change to its own contract.
8181+- `src/lib/server/services/ledger.ts` — `LedgerFilters` gains `source`;
8282+ `LedgerRow` gains origin.
8383+- `src/lib/server/services/rules.ts` — `applyRulesToUncategorized` is called
8484+ post-commit. It is already unscoped and database-wide, so it needs no change.
8585+- New service for CSV parsing/mapping/duplicate detection; new Settings routes for
8686+ the import wizard and the import record.
8787+8888+**Risk**: The overlap between a CSV's range and existing synced data is a region,
8989+not a boundary — the short-history account accumulates synced rows daily, so both
9090+sources can claim the same stretch of time. Duplicate review is therefore the
9191+central safeguard, not an edge case. Where both sources claim a transaction, the
9292+synced row wins: it carries a real SimpleFIN id, reconciles correctly under future
9393+syncs, and already holds categorization history.
···11+## ADDED Requirements
22+33+### Requirement: Import attribution to an existing account
44+55+The system SHALL require every CSV import to be attributed to exactly one account
66+that already exists in the database. The system SHALL NOT create, rename, or
77+otherwise modify an account as a result of an import, preserving the
88+`account-management` rule that accounts originate only from sync data. Accounts in
99+state `HIDDEN` SHALL NOT be offered as import targets.
1010+1111+#### Scenario: User selects a target account
1212+1313+- **WHEN** a user begins a CSV import
1414+- **THEN** the system requires them to choose one existing non-hidden account, and
1515+ every transaction ingested from that file is attached to that account
1616+1717+#### Scenario: No account selected
1818+1919+- **WHEN** a user attempts to proceed without choosing a target account
2020+- **THEN** the import does not proceed and no rows are ingested
2121+2222+### Requirement: Verbatim archival before normalization
2323+2424+The system SHALL store the uploaded file's bytes verbatim in an `imports` record
2525+before any parsing, mapping, or normalization occurs. The archived payload SHALL
2626+never be mutated or deleted by the application. The system SHALL store the chosen
2727+column mapping and the duplicate decisions on the same record, so that the
2828+outcome of an import is a deterministic function of the archived record alone.
2929+3030+#### Scenario: File archived on upload
3131+3232+- **WHEN** a user uploads a CSV file
3333+- **THEN** an `imports` row is committed containing the exact uploaded bytes with
3434+ status `draft`, before any row is parsed
3535+3636+#### Scenario: Import is replayable
3737+3838+- **WHEN** the import logic is re-run over an archived record with its stored
3939+ mapping and decisions
4040+- **THEN** it produces the same set of transactions as the original run
4141+4242+#### Scenario: Unparseable file
4343+4444+- **WHEN** an uploaded file cannot be parsed as CSV
4545+- **THEN** the archived record is retained, the failure is stated plainly with the
4646+ reason, and no transactions are ingested
4747+4848+### Requirement: Column mapping
4949+5050+The system SHALL detect a CSV's date, amount, and description columns from its
5151+header row where possible, and SHALL require the user to confirm or correct the
5252+mapping before commit. The system SHALL support amounts expressed as a single
5353+signed column and as separate debit and credit columns. The system SHALL accept
5454+the negative conventions common to bank exports, including parenthesized values
5555+and trailing minus signs, and SHALL convert amounts to integer cents without
5656+floating-point arithmetic. The system SHALL map the file's date to the
5757+transaction's posted timestamp and record imported transactions as not pending;
5858+when the file exposes a distinct transaction date, the system SHALL map it to the
5959+transaction date.
6060+6161+#### Scenario: Headers auto-detected
6262+6363+- **WHEN** a CSV's header row contains recognizable date, amount, and description
6464+ columns
6565+- **THEN** the system pre-selects them and presents the mapping for confirmation
6666+6767+#### Scenario: User corrects a wrong guess
6868+6969+- **WHEN** the auto-detected mapping is wrong
7070+- **THEN** the user can reassign any field to any column before committing
7171+7272+#### Scenario: Parenthesized negative
7373+7474+- **WHEN** an amount column contains `(12.34)`
7575+- **THEN** it is ingested as `-1234` integer cents
7676+7777+#### Scenario: Separate debit and credit columns
7878+7979+- **WHEN** a file expresses amounts as separate debit and credit columns
8080+- **THEN** the user can map both, and each row resolves to a single signed integer
8181+ cent amount
8282+8383+#### Scenario: Imported rows appear in reports
8484+8585+- **WHEN** an imported transaction is committed
8686+- **THEN** it is recorded as not pending with its posted timestamp set, and
8787+ appears in monthly reports for the month of that timestamp
8888+8989+### Requirement: Stable synthetic identity
9090+9191+The system SHALL derive a stable, deterministic identifier for each imported
9292+transaction from its content — the target account, date, amount, and description —
9393+combined with an occurrence index distinguishing rows that are otherwise
9494+identical within the same file. Identifiers SHALL be namespaced so they are
9595+distinguishable from provider-supplied identifiers. Importing a file whose rows
9696+have already been ingested under the same identifiers SHALL NOT create duplicate
9797+rows.
9898+9999+#### Scenario: Same file imported twice
100100+101101+- **WHEN** a user imports a file and then imports the identical file again
102102+- **THEN** no new transactions are created and the second import reports that
103103+ every row already exists
104104+105105+#### Scenario: Overlapping files
106106+107107+- **WHEN** a user imports a January–March file and then a February–April file into
108108+ the same account
109109+- **THEN** the February–March rows are recognized as already ingested and only the
110110+ April rows are added
111111+112112+#### Scenario: Genuinely identical transactions
113113+114114+- **WHEN** a file contains two rows with the same date, amount, and description,
115115+ representing two real transactions
116116+- **THEN** both are ingested as separate transactions
117117+118118+### Requirement: Potential duplicate review
119119+120120+The system SHALL, before commit, identify each candidate row that would create a
121121+transaction resembling one the target account already holds — matching on
122122+identical amount and an effective date within one day, against non-removed
123123+transactions regardless of their origin. The system SHALL NOT match on
124124+description, because the same transaction is rendered differently by different
125125+sources. The system SHALL present each flagged pair to the user with both
126126+descriptions shown, and SHALL default the flagged row to being skipped. The user
127127+SHALL be able to override any flagged row to be imported.
128128+129129+#### Scenario: Cross-source duplicate flagged
130130+131131+- **WHEN** a candidate row has the same amount and date as an existing synced
132132+ transaction in the target account, but a differently worded description
133133+- **THEN** the row is flagged for review, both descriptions are shown side by
134134+ side, and it defaults to skipped
135135+136136+#### Scenario: Default skip retains the synced row
137137+138138+- **WHEN** a user commits an import without changing any duplicate decision
139139+- **THEN** every flagged row is skipped, the existing transactions are left
140140+ untouched, and no duplicate is created
141141+142142+#### Scenario: User keeps a false positive
143143+144144+- **WHEN** a flagged row is in fact a distinct transaction and the user marks it to
145145+ be imported
146146+- **THEN** it is ingested as a new transaction alongside the existing one
147147+148148+#### Scenario: Preview before commit
149149+150150+- **WHEN** a mapped file is ready for review
151151+- **THEN** the system states the parsed date range, the number of rows to be
152152+ imported, the number already present, and the number flagged as potential
153153+ duplicates
154154+155155+### Requirement: Additive-only ingestion
156156+157157+An import SHALL only insert transactions. The system SHALL NOT, as a result of an
158158+import, modify or remove any existing transaction, reconcile pending transactions,
159159+change any account's state, or write balance snapshots. Reconciliation and
160160+feed-authority behavior SHALL remain exclusive to sync.
161161+162162+#### Scenario: Pending transactions untouched
163163+164164+- **WHEN** an import is committed into an account that holds pending transactions
165165+ absent from the CSV
166166+- **THEN** those pending transactions remain unchanged and are not removed
167167+168168+#### Scenario: Existing transaction not overwritten
169169+170170+- **WHEN** a candidate row resolves to an identifier already present in the account
171171+- **THEN** the existing transaction is left exactly as it was
172172+173173+#### Scenario: No balance snapshots
174174+175175+- **WHEN** an import is committed
176176+- **THEN** no balance snapshot is written and the net worth report is unaffected
177177+178178+#### Scenario: Account state unaffected
179179+180180+- **WHEN** an import is committed into an `ACTIVE` account
181181+- **THEN** the account's state and `last_successful_data_at` are unchanged
182182+183183+### Requirement: Rule application after import
184184+185185+The system SHALL apply existing categorization rules to imported transactions once
186186+the import is committed, using the same rule precedence and the same `rule`
187187+categorization event source as sync. Imported transactions SHALL NOT introduce a
188188+new categorization event source.
189189+190190+#### Scenario: Backfilled history categorizes itself
191191+192192+- **WHEN** an import is committed and existing rules match some of the imported
193193+ transactions
194194+- **THEN** those transactions are categorized with `rule` events recording the
195195+ winning rule, exactly as if they had arrived by sync
196196+197197+#### Scenario: Manual decisions respected
198198+199199+- **WHEN** rules are applied after an import
200200+- **THEN** transactions whose latest categorization event is `manual` are not
201201+ re-categorized
202202+203203+### Requirement: Import record and undo
204204+205205+The system SHALL record every import and surface the record in Settings, showing
206206+the file name, target account, parsed date range, counts of rows imported and
207207+skipped, and the commit time. Each imported transaction SHALL record which import
208208+produced it. The system SHALL allow a committed import to be undone as a unit,
209209+removing the transactions it produced without deleting any categorization event
210210+history. An undone import SHALL be re-importable after correcting its mapping.
211211+212212+#### Scenario: Import listed in settings
213213+214214+- **WHEN** a user opens the import record in Settings
215215+- **THEN** each import is listed with its file name, account, date range, counts,
216216+ and commit time
217217+218218+#### Scenario: Undo removes only that import's rows
219219+220220+- **WHEN** a user undoes an import
221221+- **THEN** exactly the transactions that import produced are removed from the
222222+ ledger and reports, no synced transaction is affected, and the import is marked
223223+ undone
224224+225225+#### Scenario: Event history survives undo
226226+227227+- **WHEN** an imported transaction had been categorized and its import is undone
228228+- **THEN** the transaction no longer appears in the ledger or reports, and its
229229+ categorization events remain in the append-only log
230230+231231+#### Scenario: Re-import after a corrected mapping
232232+233233+- **WHEN** a user undoes an import made with a wrong mapping and re-imports the
234234+ same file with a corrected mapping
235235+- **THEN** the corrected transactions are ingested and the undone import's rows do
236236+ not reappear
···11+## MODIFIED Requirements
22+33+### Requirement: Transaction ledger
44+The system SHALL provide a ledger view of transactions filterable by account, category (including uncategorized), month, pending status, and source (synced vs. imported), and searchable by free text. Text search SHALL match case-insensitively against the raw description, payee, and memo fields and against the effective displayed name produced by rule display-name overlays, so search finds what the user sees. Search SHALL compose with all structured filters, and the source filter SHALL compose with all other filters. The ledger shows date, account, displayed name (rule overlay applied when present), amount, category, and a provenance indicator (rule vs. person). The ledger SHALL NOT mark a transaction's source on the row itself; a transaction's origin — synced from a connection, or imported from a named file — SHALL be shown in its history panel. The ledger is the surface for manual categorization and for creating rules from transactions.
55+66+#### Scenario: Filter to uncategorized
77+- **WHEN** a user filters the ledger to uncategorized transactions
88+- **THEN** only transactions with no current category are listed, ready for manual assignment
99+1010+#### Scenario: Provenance indicator
1111+- **WHEN** a categorized transaction is displayed
1212+- **THEN** the row indicates whether the category came from a rule, a person (with their identity), or reconciliation carry-forward
1313+1414+#### Scenario: Search matches a renamed transaction
1515+- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user searches the ledger for "rent"
1616+- **THEN** the transaction is found, even though no raw field contains "rent"
1717+1818+#### Scenario: Search composes with filters
1919+- **WHEN** a user searches for "netflix" with a month filter active
2020+- **THEN** only that month's transactions matching the text (raw fields or displayed name) are listed
2121+2222+#### Scenario: Filter to imported transactions
2323+- **WHEN** a user filters the ledger by source to imported transactions
2424+- **THEN** only transactions produced by a CSV import are listed
2525+2626+#### Scenario: Source filter composes
2727+- **WHEN** a user filters by source to imported with an account and month filter active
2828+- **THEN** only that account's imported transactions in that month are listed
2929+3030+#### Scenario: Origin shown in history
3131+- **WHEN** a user opens the history panel of a transaction that came from a CSV import
3232+- **THEN** the panel states that it was imported and names the file and import date, distinguishing it from a transaction synced from a connection
···11+## 1. Schema
22+33+- [ ] 1.1 Add `migrations/005_csv_import.sql` creating the `imports` table: `id`,
44+ `account_id` (NOT NULL, FK to accounts), `filename`, `uploaded_at`, `payload`
55+ (verbatim bytes, never mutated), `mapping` (JSON, nullable), `decisions` (JSON,
66+ nullable), `status` NOT NULL DEFAULT `'draft'` CHECK in (`draft`, `committed`,
77+ `undone`), `committed_at`, `undone_at`.
88+- [ ] 1.2 In the same migration, `ALTER TABLE transactions ADD COLUMN import_id
99+ INTEGER REFERENCES imports (id)` (nullable; NULL means synced) and add an index
1010+ on `transactions (import_id)`.
1111+- [ ] 1.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a
1212+ populated database and that existing transactions read back with
1313+ `import_id IS NULL`.
1414+1515+## 2. CSV parsing and mapping (pure)
1616+1717+- [ ] 2.1 Add `@std/csv` (JSR) as a dependency. Do not hand-roll parsing —
1818+ quoted delimiters, embedded newlines, and BOMs are the failure modes.
1919+- [ ] 2.2 Create `src/lib/server/services/csv-import.ts` with a pure
2020+ `parseCsv(payloadText)` returning header row plus data rows, tolerating a BOM
2121+ and CRLF line endings.
2222+- [ ] 2.3 Implement pure `detectMapping(headers)` returning best-guess column
2323+ assignments for date, amount (single signed column or debit/credit pair), and
2424+ description, plus optional payee/memo and a distinct transaction-date column.
2525+- [ ] 2.4 Implement pure `normalizeCsvAmount(raw)` handling parenthesized
2626+ negatives `(12.34)`, trailing minus `12.34-`, currency symbols, and thousands
2727+ separators, emitting a signed decimal string for the existing
2828+ `parseAmountToCents`. Do not loosen `parseAmountToCents` itself — its strict
2929+ regex is the sync path's contract.
3030+- [ ] 2.5 Implement pure `normalizeCsv(payloadText, mapping, accountId)` producing
3131+ candidate rows with `posted` set from the file's date, `pending = 0`,
3232+ `transacted_at` set only when the mapping names a distinct transaction-date
3333+ column, and amounts as integer cents.
3434+- [ ] 2.6 Implement the synthetic identity: `csv:` + hash over (accountId, date,
3535+ amountCents, description) plus an occurrence index among identical rows within
3636+ the file.
3737+- [ ] 2.7 Unit-test 2.2–2.6 in `csv-import.test.ts`: BOM/CRLF, both amount modes,
3838+ each negative convention, date parsing, and that two identical rows receive
3939+ distinct ids while a reordered or superset file re-derives the same id set.
4040+4141+## 3. Duplicate detection
4242+4343+- [ ] 3.1 Implement `findPotentialDuplicates(db, accountId, candidates)` matching
4444+ each candidate against non-removed transactions in the account by identical
4545+ `amount_cents` and effective date within ±1 day (compare against
4646+ `COALESCE(posted, transacted_at)`). Never match on description.
4747+- [ ] 3.2 Return, per candidate, one of: `new`, `already-present` (its synthetic
4848+ id already exists — never surfaced to the user), or `flagged` with the existing
4949+ transaction's date, amount, and description for side-by-side display.
5050+- [ ] 3.3 Test: a cross-source duplicate with differently worded descriptions is
5151+ flagged; a row one day off is flagged; a row two days off is not; an
5252+ already-ingested row reports `already-present` rather than `flagged`.
5353+5454+## 4. Import lifecycle and additive ingestion
5555+5656+- [ ] 4.1 Implement `createDraftImport(db, accountId, filename, payload)` writing
5757+ the `imports` row with verbatim payload and `status = 'draft'` before any
5858+ parsing.
5959+- [ ] 4.2 Implement `saveMapping` and `saveDecisions` persisting to the draft row,
6060+ so the archived record alone determines the outcome.
6161+- [ ] 4.3 Implement `commitImport(db, importId)` — an **insert-only** writer.
6262+ It MUST NOT reconcile, MUST NOT sweep stale pending rows, MUST NOT update
6363+ existing transactions, MUST NOT write balance snapshots, and MUST NOT touch
6464+ account state. Do not call or extend `ingestTransactions`; its stale-pending
6565+ sweep (`sync.ts:291-301`) would soft-delete every pending row absent from the
6666+ CSV.
6767+- [ ] 4.4 In `commitImport`, skip candidates marked skipped and those whose
6868+ synthetic id already exists; revive a soft-removed row matching the unique key
6969+ by clearing `removed_at` and reassigning `import_id`; stamp `import_id` on every
7070+ inserted row; set `status = 'committed'` and `committed_at`. Run the whole
7171+ commit in one transaction.
7272+- [ ] 4.5 Call `applyRulesToUncategorized(db)` after the commit transaction
7373+ succeeds. No new categorization event source.
7474+- [ ] 4.6 Test: importing into an account holding pending rows leaves them
7575+ untouched; no snapshots are written; account state and
7676+ `last_successful_data_at` are unchanged; re-importing the same file inserts
7777+ nothing; an overlapping file inserts only the new rows; committed rows carry
7878+ `import_id` and are picked up by rules.
7979+8080+## 5. Undo
8181+8282+- [ ] 5.1 Implement `undoImport(db, importId)` soft-removing (`removed_at = now`)
8383+ every transaction with that `import_id`, setting `status = 'undone'` and
8484+ `undone_at`, in one transaction. Never hard-delete — categorization events
8585+ reference `transaction_id` and the event log is append-only.
8686+- [ ] 5.2 Test: undo removes exactly that import's rows from ledger and report
8787+ scope, leaves synced rows untouched, preserves categorization events for the
8888+ removed rows, and a corrected re-import afterwards does not resurrect the old
8989+ rows.
9090+9191+## 6. Ledger surfacing
9292+9393+- [ ] 6.1 Add `source?: 'synced' | 'imported'` to `LedgerFilters` in
9494+ `src/lib/server/services/ledger.ts`, translating to `t.import_id IS NULL` /
9595+ `IS NOT NULL`, composing with every existing filter.
9696+- [ ] 6.2 Add origin to `LedgerRow` (import id, file name, import date; null when
9797+ synced) via a join through `import_id`.
9898+- [ ] 6.3 Test in `ledger.test.ts`: the source filter composes with account,
9999+ month, category, pending, and text search.
100100+- [ ] 6.4 Add the source filter control to the ledger page alongside the existing
101101+ filters. Add no per-row badge — within a backfilled range every row is imported,
102102+ so a per-row mark carries no information where it appears.
103103+- [ ] 6.5 Add an origin line to the top of the existing history panel: "Imported
104104+ from `<file>` · `<date>`" or "Synced from SimpleFIN".
105105+106106+## 7. Import wizard (Settings)
107107+108108+- [ ] 7.1 Add a Settings route for the import wizard. Step 1: file upload plus
109109+ target-account picker (non-hidden accounts only), archiving on submit and
110110+ redirecting to the draft's id.
111111+- [ ] 7.2 Step 2: mapping confirmation, pre-filled from `detectMapping`, every
112112+ field reassignable, with a few parsed sample rows rendered so a wrong guess is
113113+ visible.
114114+- [ ] 7.3 Step 3: preview stating the parsed date range, rows to import, rows
115115+ already present, and rows flagged — the guard against a catastrophically wrong
116116+ date mapping.
117117+- [ ] 7.4 Step 4: duplicate review listing each flagged row against its existing
118118+ match, both descriptions shown, defaulting to skip, each flippable to keep.
119119+- [ ] 7.5 Commit action, then a result summary linking to the ledger filtered to
120120+ that account and source.
121121+- [ ] 7.6 Style per DESIGN.md: tabular numerals on money and dates, calm empty and
122122+ error states (one sentence plus one action), no new red unless data is at risk.
123123+124124+## 8. Import record (Settings)
125125+126126+- [ ] 8.1 Add a Settings section listing imports: file name, account, parsed date
127127+ range, rows imported, rows skipped, commit time, status. Not in the main nav.
128128+- [ ] 8.2 Add the undo action with a confirmation stating exactly how many
129129+ transactions will be removed.
130130+- [ ] 8.3 Render an empty state for the no-imports-yet case.
131131+132132+## 9. Verification
133133+134134+- [ ] 9.1 Run `deno task test` and `deno task check`.
135135+- [ ] 9.2 Drive the wizard end to end against a real bank CSV export in dev:
136136+ confirm the gap closes in the ledger, the month reports for backfilled months
137137+ change as expected, and the net worth chart is unchanged (no snapshots written).
138138+- [ ] 9.3 Verify a sync runs cleanly after an import: pending rows still
139139+ reconcile, and no imported row is disturbed.
140140+- [ ] 9.4 Exercise undo on a real import and confirm the ledger and reports return
141141+ to their pre-import state.