Writing
·9 min read

Building a Privacy-First Finance Tracker: CSV Import, AI Categorization, and No Backend

I wanted a personal finance app that didn't send my bank data to third-party servers. So I built one: CSV import with format inference, Claude-powered transaction categorization, and multi-scenario financial modeling — all client-side.

engineeringprivacyfinanceai

I wanted a personal finance tracker that didn't send my bank data to a third-party server. So I built one. Here's how I handled CSV import, AI-powered categorization, and multi-scenario financial modeling — all client-side.

The privacy problem with finance apps

Every major personal finance app — Mint, YNAB, PocketSmith — connects to your bank account via a third-party data aggregator like Plaid or Basiq. The pitch is seamless sync. The cost is that your transaction data lives on servers you don't control, processed by a company whose business model may involve analyzing it.

For most people, this is a reasonable tradeoff. For me, it felt like a constraint I could engineer around. Banks already provide CSV exports. If I built the processing pipeline myself, I could own the data entirely.

The constraint became the design: no backend, no accounts, no data transmission. Everything lives in the browser. LocalStorage for persistent state, client-side computation for all the math, the Anthropic API for AI-powered categorization — called directly from the browser, with the API key stored locally, never passing through any server I control.

The CSV import problem is harder than it looks

Different banks export CSVs in different formats. Scotiabank uses MM/DD/YYYY. ASB uses DD/MM/YYYY. Some banks put debits and credits in separate columns. Others use a single “Amount” column with negative values for debits. Column names vary: “Transaction Date”, “Date”, “Posting Date”, “Trxn Date”. Some include currency columns. Some include running balances.

The naive approach — assume a fixed column structure — breaks as soon as anyone with a different bank tries to import. I needed a parser that could infer structure.

The solution I landed on: a two-pass inference system. First pass reads the header row and scores column name candidates against a known dictionary of bank-specific header synonyms. Second pass validates by checking that the inferred date column actually parses as dates, the inferred amount column contains numbers, and the description column contains non-empty strings.

// Column name inference — maps bank-specific names to canonical fields
const HEADER_MAP: Record<string, string> = {
  // Dates
  "transaction date": "date",
  "date": "date",
  "posting date": "date",
  "trxn date": "date",
  "value date": "date",
  // Amounts
  "amount": "amount",
  "debit amount": "debit",
  "credit amount": "credit",
  "transaction amount": "amount",
  "net amount": "amount",
  // Descriptions
  "description": "description",
  "transaction description": "description",
  "details": "description",
  "narrative": "description",
  "merchant": "description",
};

function inferColumns(headers: string[]): ColumnMap | null {
  const map: ColumnMap = {};

  headers.forEach((h, i) => {
    const normalized = h.toLowerCase().trim();
    const canonical = HEADER_MAP[normalized];
    if (canonical && !map[canonical]) {
      map[canonical] = i;
    }
  });

  // Must have at least date + (amount OR both debit/credit) + description
  const hasDate = "date" in map;
  const hasAmount = "amount" in map || ("debit" in map && "credit" in map);
  const hasDescription = "description" in map;

  return hasDate && hasAmount && hasDescription ? map : null;
}

Date parsing required its own care. The ambiguity between DD/MM/YYYY and MM/DD/YYYY is real: 01/02/2025 could be January 2nd or February 1st. I solved this with a format hint passed during import — the user picks their bank from a dropdown, and the bank's known date format is used for parsing. If they're importing from an unknown bank, they select the format manually. Not elegant, but reliable.

AI-powered categorization

Transaction descriptions from banks are notoriously cryptic. “POSA COUNTDOWN PAPAKURA 1025” means supermarket. “GOOGLE *YOUTUBE” means subscription. “ATMWD HSBC BRANCH” means ATM withdrawal. No regex is going to reliably parse that taxonomy.

I added Claude as an optional categorization step. When the user imports a CSV, uncategorized transactions can be sent to the API in batches. The prompt instructs the model to assign one of a fixed set of categories (Groceries, Transport, Dining, Entertainment, Health, Utilities, Subscriptions, Income, Transfer, Other) based on the description string alone.

The results are remarkably good. Claude handles merchant name variations, international characters in bank descriptions, and the general messiness of real transaction data better than any rule-based system I could have built. The key design decision was batching — sending 50 transactions per API call rather than one per transaction, which reduces cost from dollars to cents for a typical month of data.

const systemPrompt = `You are a transaction categorizer. 
For each transaction description, return exactly one category from this list:
Groceries, Transport, Dining, Entertainment, Health, 
Utilities, Subscriptions, Income, Transfer, Other

Return a JSON array matching the input order. Example:
Input: ["COUNTDOWN PAPAKURA", "UBER EATS", "SALARY PAYMENT"]
Output: ["Groceries", "Dining", "Income"]`;

async function categorizeTransactions(
  descriptions: string[]
): Promise<string[]> {
  const response = await anthropic.messages.create({
    model: "claude-3-haiku-20240307", // cheap + fast for structured tasks
    max_tokens: 1024,
    messages: [{
      role: "user",
      content: `Categorize these transactions:\n${JSON.stringify(descriptions)}`
    }],
    system: systemPrompt,
  });

  const text = response.content[0].type === "text" ? response.content[0].text : "";
  return JSON.parse(text);
}

I use Claude Haiku rather than Sonnet for this step. Categorization is a structured extraction task — the model doesn't need deep reasoning, just pattern matching. Haiku is 20x cheaper and returns results in under a second per batch. The savings matter when you're calling the API on your own dime.

The financial model: net worth, interest, scenarios

Once transactions are imported and categorized, the app becomes a financial modeling environment. The core calculation is net worth: assets (savings + investments + BTC) minus liabilities (student loan balance). Straightforward arithmetic, but the details matter.

The interest spread calculation is one I find genuinely useful. My student loan charges interest at a rate higher than my savings account earns. The money sitting in savings is technically losing me money compared to paying down the loan. The app surfaces this: “your savings are earning $X/yr while the loan costs $Y/yr — the $Z in savings is losing you $W/yr vs. paying it down.”

For projections, I built a scenario model with three trajectories — pessimistic, base case, optimistic — each parameterized by income growth, investment return rate, and extra loan repayment. The model runs monthly compounding over a user-selected timeframe (6 months to 10 years) and plots all three scenarios simultaneously.

function projectScenario(
  snapshot: FinancialSnapshot,
  params: ScenarioParams,
  months: number
): ProjectionPoint[] {
  let { savings, loan, investments, btcAmount, btcPrice } = snapshot;
  const results: ProjectionPoint[] = [];

  for (let m = 0; m < months; m++) {
    // Monthly income → savings contribution
    const monthlyIncome = snapshot.monthlyIncome * Math.pow(1 + params.incomeGrowthRate / 12, m);
    const monthlySavings = monthlyIncome * snapshot.savingsRate;

    // Loan: compound interest + minimum payment + extra repayment
    const loanInterest = loan * (snapshot.loan.interestRate / 12);
    const loanPayment = snapshot.loan.minimumPayment + params.extraRepayment;
    loan = Math.max(0, loan + loanInterest - loanPayment);

    // Savings: deposit + interest
    savings = savings + monthlySavings + (savings * snapshot.savingsInterestRate / 12);

    // Investments: compound return
    investments = investments * (1 + params.investmentReturn / 12);

    // BTC: assumed constant price in base case, parameterized in scenarios
    const btcValue = btcAmount * btcPrice * params.btcMultiplier;

    const netWorth = savings + investments + btcValue - loan;
    results.push({ month: m + 1, netWorth, savings, loan, investments, btcValue });
  }

  return results;
}

The model is intentionally simple. Real financial modeling involves tax effects, inflation, variable interest rates, and income uncertainty that this doesn't capture. But the value isn't in precision — it's in direction. Seeing three curves diverging over five years communicates “these decisions compound” better than a spreadsheet does.

What privacy-first forced me to do better

The no-backend constraint turned out to improve the design in ways I didn't anticipate.

Data portability: Since there's no server to export from, I built export-to-JSON from day one. All your data is always in a format you can download, inspect, and import elsewhere. This is a feature you rarely see in hosted apps because it removes the switching cost.

Faster iteration: No authentication, no API, no database schema migrations. Changing the data model means updating a TypeScript interface and a migration function that runs on load. I shipped the subscriptions detection feature in two hours because there was no backend layer to thread through.

Zero operating cost: The app is deployed on Vercel's free tier. No database hosting, no API service, no auth provider. Fixed infrastructure cost is literally $0. This matters when you're building portfolio projects — you want them live and accessible, not taken down because a free tier expired.

The tradeoff is obvious: no multi-device sync. Your data lives in one browser. If you clear localStorage or switch computers, it's gone unless you manually export and import. For a personal finance tool used on one device, this is acceptable. For a team product, it would be a dealbreaker.

Lessons from building in a sensitive domain

Finance is different from most domains I've built in. The data is real, the stakes are real, and users bring strong intuitions about accuracy and trust that they don't bring to, say, a task management app. A few things I learned:

Show your math. Every aggregate number in the app has a drill-down. The net worth figure on the dashboard links to a breakdown. The “interest spread costing you X” card shows the formula. Users don't trust black boxes with their money, and rightly so. Transparent computation builds confidence.

Fail loudly on import. A silent parsing error is the worst outcome in a financial tool. If a row fails to parse, it should appear in a visible error list, not silently disappear. I spent a significant chunk of the CSV import work on error handling and user-facing diagnostics, which felt disproportionate until I realized this is exactly where trust is won or lost.

Don't moralize. Early versions had too much interpretive framing — red for “bad” spending categories, green for “good”. Users pushed back (via my own use of the tool, testing different situations). Whether spending on entertainment is “bad” depends entirely on the person's situation. The app now shows the data neutrally and lets the user interpret it.

Building financial tools taught me something that applies beyond finance: the gap between “technically correct” and “trustworthy” is wider than you think, and closing it is product work, not engineering work.