spend-lens: A Private Personal Finance Analyzer That Never Sends Your Data Anywhere

spend-lens: A Private Personal Finance Analyzer That Never Sends Your Data Anywhere

Every budgeting app on the market - Mint, Copilot, YNAB, Monarch - shares the same architecture: you upload your bank transaction data to their servers, and they send back dashboards. That data is valuable, and you are usually paying with it even when the monthly subscription fee is zero. This post is about a different architecture, and a working reference implementation of it: labs.kargin-utkin.com/spend-lens.

spend-lens is a personal finance analyzer where your bank CSV never leaves the browser tab. The entire app is static HTML + JavaScript. You drop a Chase, Bank of America, Amex, or Wells Fargo export onto the page; the browser's File API reads it; every byte of categorization, deduplication, analysis, and ML inference happens inside that tab. Close the tab, and your transaction data is gone.

This is not a trust claim. It is an architectural property. Open DevTools, go to the Network tab, drop a CSV, and verify for yourself that zero requests contain your file data. That is the core thesis of the project.

Why build this

Personal finance is a privacy worst-case. Your transaction stream reveals where you eat, where you shop, where you live, what subscriptions you keep, when you travel, whether you gamble, whether you are donating to political causes, what medical providers you see. A detailed month of statements is a biographical document.

The industry answer has been "trust us, we encrypt it at rest." The better answer is compute where the data lives - put the logic next to the file on the user's machine, and never send the file anywhere. Browsers have been capable of this for a decade. The reason nobody does it is business-model, not technical.

spend-lens is the architectural counter-example. Everything a hosted app does - categorize, deduplicate, detect subscriptions, compare months, find savings - runs locally. The user gets the insights. The company (there is no company) gets nothing.

What it does

Drop one or many CSVs onto the drop zone. The app auto-detects the bank format from column headers (Chase, BofA, Amex, Wells Fargo, and a heuristic fallback for generic exports). Multiple files get merged with cross-file deduplication - if you upload April and April-May-combined statements, the overlapping rows are detected and skipped, and a fully-duplicate file is rejected with a clear message.

From there, the app runs a standard personal-finance analysis stack:

  • Top 3 savings opportunities - a synthesizer card that ranks subscription stacking, bank fees, dining frequency, and streaming-service overlap by annualized impact
  • Category pie + breakdown - top-8 categories with an Other rollup
  • Spending over time - line chart with auto weekly / monthly bucketing, spend plus income
  • Month-over-month comparison - when data spans two or more months, per-category delta plus percent change, flagging new and stopped categories, ranked by biggest movers
  • Subscription creep detector - median-interval and amount-variance detection of recurring charges (weekly / biweekly / monthly / quarterly / annual), with annualized totals so you see the true cost of that $14.99/month service
  • Top uncategorized merchants - normalized-name rollup of what the categorizer missed, so you can either tag them inline or contribute a rule back to the codebase

Nothing here is novel in what it shows you. The novelty is where the computation happens.

The categorization problem

Category assignment is where naive personal-finance tools fall over. "PENNYMAC LOAN SRVS" is your mortgage. "SQ *TAPROOM" is dining. "AMZN Mktp US*R2J4K" is shopping. Out of the box, most tools either show you raw merchant strings (useless) or ask you to manually tag every transaction (tedious).

spend-lens uses a two-layer approach.

Layer 1: rule-based (categories.js)

Roughly 200 US merchants mapped to 11 categories via keyword rules. Deterministic, fast, no ML required. Covers the long tail of common transactions - groceries, gas, utilities, major retailers, well-known subscriptions. The file is intentionally small and forkable. If your bank statements mention merchants the defaults miss, adding a keyword is a one-line code change.

Layer 2: in-browser ML embedding fallback (classifier.js)

For merchants the rules miss, there is an opt-in "AI" button. Clicking it lazy-loads Xenova/all-MiniLM-L6-v2 - a compact sentence-transformers model - via Transformers.js. Model weights (~22MB) are fetched from HuggingFace once and cached forever in the browser's HTTP cache. Inference runs entirely on the client via WASM / WebGPU.

The classifier embeds each uncategorized merchant name into a 384-dimensional vector, compares it against category centroids built from exemplar phrases ("grocery store, supermarket, bodega, food market" for Groceries, and so on), and assigns the best match above a cosine-similarity threshold. Below the threshold, the merchant stays in the "uncategorized" bucket.

This is sentence-transformers running in your browser tab. Three years ago this would have required a GPU server. Today it is 22MB of weights and a few hundred lines of glue code.

Layer 3: user-taught categorization

When the rule-based pass and the ML pass both miss, you can click any uncategorized merchant and pick a category from a dropdown. This does two things:

  1. Persists your choice to localStorage on your device, so every future session remembers it
  2. If the AI model is loaded, adds the merchant name as an exemplar to the chosen category - the centroid vector updates, and similar unknown merchants you have not tagged yet start classifying correctly

That second behavior is few-shot learning running on the user's device. One correction teaches the system a pattern. Tag "SHELL OIL 573" as Gas, and "BP#451" and "CHEVRON #2234" start landing in the same bucket without further input. All of it on-device. None of it shared.

The architecture, in four files

spend-lens/
├── index.html           ← UI, loads deps from CDN
├── style.css            ← dark theme
├── script.js            ← state, rendering, savings analysis
├── categories.js        ← rule-based categorizer (forkable merchant list)
├── classifier.js        ← Transformers.js embedding fallback
├── sample_transactions.csv
└── README.md

Dependencies, all loaded from CDN, all optional to the point of being removable:

No build system. No frameworks. No server. You can run the whole thing air-gapped by cloning the repo, serving index.html from python3 -m http.server, and (if you want full offline) swapping the CDN script tags for locally-hosted copies of PapaParse and Chart.js.

Why this matters for the industries that can't cloud-ship their data

The spend-lens privacy model is not a personal-finance quirk. It is the same pattern that InnovaTek deploys inside regulated companies - healthcare providers, financial services, franchise operations with contractual data-locality clauses - where the data genuinely cannot leave the environment and a third-party API is not an option.

The architectural ingredients are the same. Rule-based logic where rules are sufficient. Modern transformer models where rules are not, deployed on infrastructure the client controls. User feedback loops that adapt the system over time without shipping corrections to a vendor. Complete on-device or on-premise operation. An audit trail that answers "can you prove data did not leave?" with "yes, here is the Network tab" or "yes, here is the VPC flow log."

spend-lens is a consumer toy that demonstrates the pattern. The same pattern scales up to hospital operations, insurance underwriting, franchise POS analytics, and any regulated environment where the business decision is between "build it on-prem" and "do not build it at all."

Try it

Drop your own statement. Open DevTools. Confirm nothing leaves. Close the tab. That is the entire product.

Leave a Comment