A local-first app isn't useful if you can't get your data into it. Bank APIs require servers. Screen scraping is fragile. But CSV and QIF files? Those work everywhere, offline, forever. So that's what we built.
Why File Import (Not Bank APIs)
Most finance apps connect to banks via Plaid, Yodlee, or similar aggregators. That requires:
- A server running 24/7 to handle OAuth callbacks
- Monthly per-user fees to the aggregator
- Storing user credentials (even encrypted)
- Dependency on third-party APIs that could change
For version 1.0, we chose the simplest path that doesn't compromise local-first principles: file imports. Every bank supports CSV export. Quicken users have QIF. These formats have been stable for decades.
The Import Pipeline
// The five-stage import pipeline 1. Parse β CSV rows or QIF transactions 2. Map β Column headers to fields 3. Validate β Dates, amounts, required fields 4. Preview β Show what will be imported (no writes) 5. Apply β Create transactions via LedgerService
The key insight: nothing touches the database until step 5. Preview is completely read-only. You can upload, map, preview, and cancel without any data being created.
CSV: Flexible Column Mapping
Every bank exports CSV differently. Chase has different columns than Bank of America. So we auto-detect what we can, then let you map the rest:
Auto-Detected
- "Date", "Posted", "Transaction Date"
- "Amount", "Total", "Value"
- "Description", "Payee", "Merchant"
- "Memo", "Notes", "Reference"
Debit/Credit Split
Some banks use separate columns for withdrawals and deposits. We support that too: map Debit and Credit columns, and we'll calculate the signed amount.
QIF: Quicken Migration
QIF (Quicken Interchange Format) has been around since 1988. If you're migrating from Quicken, you can export years of data and import it here:
// QIF file structure !Type:Bank D1/15/2024 // Date T-45.50 // Amount PWhole Foods // Payee MWeekly groceries // Memo LGroceries // Category ^ // End of transaction
QIF parsing handles the quirky date formats (apostrophe separators, 2-digit years) and extracts all standard fields. Categories are preserved for manual mapping.
Deterministic Duplicate Detection
What happens if you import the same file twice? Or import overlapping date ranges? We need duplicate detection that is:
- Deterministic β Same inputs always produce same result
- Explainable β You can see why something was flagged
- Overridable β You can import anyway if it's not actually a duplicate
We generate a fingerprint for each transaction:
fingerprint = accountId + ":" + YYYY-MM-DD + ":" + amount + ":" + normalizedPayee // Example "acc_xyz:2024-01-15:-4550:whole foods" // Same payee, same day, same amount, same account = likely duplicate
Preview Before Write
The preview shows you exactly what will happen:
| Metric | What It Shows |
|---|---|
| Valid rows | Transactions ready to import |
| Duplicates | Likely already exist (skipped by default) |
| Invalid | Bad dates or amounts (always skipped) |
| Date range | Earliest to latest transaction |
| Net amount | Sum of all valid transactions |
This is fully read-only. The database is never touched. You can preview a 10,000 row file, decide you don't like the mapping, and start over. Zero risk.
Import is Audited
When you confirm, every transaction is created through LedgerService. This means:
- Balance caches are synced after each transaction
- Each transaction emits a
transaction.createoperation - The import session itself emits an
import.sessionoperation
// import.session operation payload
{
sessionId: "import_abc123",
filename: "chase-2024-01.csv",
importFormat: "csv",
accountId: "acc_checking",
importedAt: 1705363200000,
transactionIds: ["tx_1", "tx_2", "tx_3", ...],
totalCount: 47,
duplicatesSkipped: 3,
dateRange: { start: ..., end: ... },
totalAmount: -125000
}Import is Undoable
Every import creates an ImportSession record that groups all imported transactions. One click to undo:
undoImportSession(sessionId) βββ Find all transactions with this importSessionId βββ Soft-delete each one (deletedAt = now) βββ Sync affected account balance βββ Mark ImportSession as deleted βββ Emit import.session.revert operation // Balance is restored. History is preserved.
Following our compensating operations pattern, undo doesn't delete anythingβit soft-deletes and creates a revert operation. The audit trail shows exactly when the import was undone.
What We Built
import-service.ts
CSV/QIF parsing, column mapping, fingerprint generation, preview, apply, undo.
ImportSession
New Dexie table tracking each import batch for audit and undo.
/import UI
Full workflow: upload, map columns, preview, confirm, view history, undo.
import.test.ts
25+ tests covering parsing, normalization, duplicates, preview, apply, undo.
What Comes Next
With import working, users can:
- Migrate from any app β Export CSV, import here
- Monthly bank syncs β Download statement, import new transactions
- Historical data β Import years of Quicken data via QIF
Future sprints may add bank API integration as an optional feature. But the core product works completely offline with file imports.
Import used to mean "give us your bank login." Now it means "drag a file." No servers. No credentials. No third-party dependencies. Just your data, in your browser.
Your financial history. Your format. Your timeline.
β The Accelerate Finance Team