Day 15: Bring Your Data With You

import csv qif onboarding quicken local-first

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:

MetricWhat It Shows
Valid rowsTransactions ready to import
DuplicatesLikely already exist (skipped by default)
InvalidBad dates or amounts (always skipped)
Date rangeEarliest to latest transaction
Net amountSum 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.create operation
  • The import session itself emits an import.session operation
// 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