actualbudget / actualbudget/actual
[Bug]: CSV import silently merges genuinely new transactions that share an amount with an existing transaction (preview shows them as new)
- Lingua principale
- TypeScript
- Stelle
- 28.7k
- Fork
- 3k
- Merge medio
- 2g 11h
- PR unite (30g)
- 65
Descrizione
🤖 _This issue was investigated and written up with the help of an AI agent; the reproduction has been verified programmatically and on-screen (demo below)._
### What happened?
When importing transactions from a CSV file, a genuinely new transaction is silently not imported if the account already contains **any transaction with the same amount within ±7 days** — even when the import preview shows the row as a plain new transaction with no merge/ignore indicator.
There are two distinct paths:
**A. Default toggle state merges instead of adds (UX trap).** When the preview fuzzy-matches a CSV row to an existing transaction, the dialog shows the row _checked_, in the 3-state toggle's default "merge with existing" state. It is not marked "ignore", so it looks like it will be imported. On import it is merged into the existing transaction — the merge only fills in blank fields (`existing.payee || trans.payee`, …), so usually nothing visibly changes and the CSV row simply vanishes.
**B. Preview/import divergence (outright bug).** Matching runs twice — once for the preview and once for the real import — and it is greedy/stateful (`hasMatched`). If the CSV contains a true duplicate _and_ a genuinely new row with the same amount, the duplicate claims the match during preview, so the new row previews as a plain new transaction with **no indicator at all**. If the user then unchecks the duplicate row, it is excluded from the import payload, matching reruns on the smaller set, and the "new" row now claims the match and is silently merged. The preview actively misrepresents what the import will do.
Root cause pointers (current `master`):
- The fuzzy-match candidate query is just _same account + same amount + date within ±7 days_: `packages/loot-core/src/server/accounts/sync.ts` (`matchTransactions`, the `fuzzyDataset` queries).
- The final "lowest fidelity" pass matches any remaining candidate even when the payee differs.
- CSV rows have no `imported_id`, so every row goes through fuzzy matching.
- The dialog's default state for a matched row is merge: `ImportTransactionsModal.tsx` (`onImportPreview` onSuccess / `onCheckTransaction` 3-state flow).
**Expected:** a row the preview presents as a new transaction is imported as a new transaction; the import honors the preview's matching decisions (e.g. pins each row to the match id found during preview) instead of re-running greedy matching on a different input set. The default "merge with existing" state is visually distinct from a plain checked row, so same-amount coincidences aren't silently swallowed.
**Actual:** the row is merged into an unrelated existing transaction with the same amount; no new transaction is created, with no error or warning.
### Demo
https://github.com/user-attachments/assets/cb4fb1d6-5cfb-45a6-bc1a-3e70e7564837
The recording and screenshots were captured in the project's e2e environment, which pins "today" to 2017-01-01, so the on-screen dates are around that day.
The import preview — the duplicate Netflix row is in merge state (layered icon, matched existing transaction nested below); the genuinely new Corner Coffee row shows a plain "+", no indicator:

After unchecking the duplicate Netflix row (the button reads "Import 1 transactions"):

The register after import — Corner Coffee was never created; it was silently merged into the existing Netflix transaction (balance still −8.99):

The CSV used: [chase-bank.csv](https://github.com/clintharris/actual/blob/csv-import-silent-drop-demo/demo/chase-bank.csv)
### How can we reproduce the issue?
UI reproduction (path B — as shown in the demo above):
1. Create an account and add a transaction: Netflix, -8.99, dated today.
2. Import this CSV (dates relative to today so the ±7-day window applies):
```csv
Date,Payee,Notes,Amount
,Netflix,Subscription,-8.99
,Corner Coffee,Latte and croissant,-8.99
```
3. In the import preview, observe: the Netflix row is matched to the existing transaction (merge state); the Corner Coffee row shows as a plain new transaction with no indicator.
4. Uncheck the Netflix row (it's a true duplicate — click its toggle twice: merge → add-as-new → unchecked). The button now reads "Import 1 transactions".
5. Click Import.
6. Expected: Corner Coffee appears in the register.
Actual: Corner Coffee is never created — it was silently merged into the existing Netflix transaction (whose `imported_payee` becomes "Corner Coffee"). No error, no warning.
Programmatic reproduction: a self-contained vitest file for `loot-core` that simulates the import dialog's exact pipeline (preview call → default toggle assignment → final import payload) and demonstrates both paths, plus a control showing the ±7-day window boundary:
https://github.com/clintharris/actual/blob/csv-import-silent-drop-repro/packages/loot-core/src/server/accounts/csv-import-silent-drop.test.ts
Run with: `ENV=node yarn workspace @actual-app/core vitest --run csv-import-silent-drop`
Happy to contribute the test (and a fix) in a PR.
### Where are you hosting Actual?
Locally via Yarn
### What browsers are you seeing the problem on?
Chrome
### Operating System
Mac OSX
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.