A multi-account personal expense tracker and financial dashboard that runs entirely on Cloudflare's edge — Workers for compute, D1 (SQLite) for storage, and Workers Static Assets for the dashboard. No VPS, no container, no build step. Upload a bank/card statement CSV and get spend analytics, balance tracking, and net-worth — served from the edge.
Privacy: this repository is code only. It ships with synthetic sample statements (
samples/) — no real financial data is included, and your own data (uploaded to your own deployment) lives only in your private D1 database.
- What it does
- Features
- Architecture
- Tech stack
- Project structure
- Data model
- Getting started (local)
- Deploy to Cloudflare
- Usage
- Supported statement formats
- API reference
- Extending it
- Development workflow
- Data-safety guarantees
- Contributing
- License
You bank across several accounts — credit cards, a savings account — and each one hands you a CSV or statement export every month. This app ingests those statements into one permanent, deduplicated ledger and turns them into a dashboard that answers two questions at once:
- Where does my money go? — categorized spend, top merchants, monthly/yearly trends, filterable by account and time period.
- Where do I stand? — current vs previous credit-card balances, credit utilisation, payment-due alerts, EMIs/loans, savings balance over time, cash flow, and overall net worth (assets − liabilities).
It is designed as a multi-account system from the ground up: adding a new account (another card, another bank) is an upload, not a code change, and adding a new account type is a small, additive extension rather than a rewrite.
- Multi-account — credit cards (liabilities) and savings accounts (assets), each a first-class account with its own identity, tracked side by side.
- Account-wise analytics — view all accounts together, a single account, or a multi-select subset. The selection is shared across the Monthly and Yearly views.
- Spend analytics — category breakdowns, top merchants, daily/monthly/yearly trends, KPIs.
- Balance & net-worth tracking — per-card current/previous balance, utilisation, nearest due date, savings balance-over-time, and a net-worth strip combining assets and liabilities.
- Cash flow — money-in vs money-out and net cash flow for accounts that report a running balance (savings).
- Smart categorization — regex rules stored in the database and editable without a redeploy. Money that merely moves (transfers, credit-card bill payments, ATM withdrawals, investments) is auto-classified and excluded from "spend" by default, with a toggle to include it.
- Deduplication — a SHA-256 hash per transaction means re-uploading the same or an overlapping statement never creates duplicates.
- Audit trail & rollback — every upload is logged; a bad import can be rolled back, removing exactly its rows and nothing else.
- Zero build step — the dashboard is plain HTML/CSS/JS + Chart.js from a CDN, served as static assets straight from the Worker.
┌────────────────────────────────────────────────┐
Browser ─────────▶ │ Cloudflare Worker (src/index.ts, Hono) │
(dashboard, │ │
static assets) │ POST /api/upload │
│ └▶ parsers.ts detect format, parse rows, │
│ │ derive account identity │
│ └▶ pipeline.ts normalize dates, clean │
│ │ merchants, SHA-256 dedup, │
│ │ regex categorization │
│ └▶ D1 (INSERT OR IGNORE + statement/EMI │
│ snapshot + account registry) │
│ │
│ GET /api/summary/*, /api/accounts/*, … │
│ └▶ D1 queries ─▶ JSON ─▶ Chart.js │
└────────────────────────────────────────────────┘
│
▼
D1 (SQLite): imports · transactions ·
accounts · category_rules · statements ·
loans · column_mappings
src/parsers.ts— format detection and parsing. Ships with three parsers (HDFC pipe-delimited card statements, HDFC savings CSV, and a generic header-based CSV) plus a statement-metadata parser for balances/limits/EMIs. Adding a bank is one moretryParseX()function; the pipeline below is untouched.src/pipeline.ts— date normalization, merchant cleanup, a deterministic SHA-256 dedup hash per row, and regex-rule categorization loaded from thecategory_rulestable.src/index.ts— the Hono app: the upload endpoint (the whole ingestion pipeline in one batched, dedup-safe write) plus the read endpoints that back the dashboard.public/— the dashboard: plain HTML/CSS/JS + Chart.js from a CDN. No framework, no bundler — served as static assets from the Worker.migrations/— the D1 schema, applied in order.
| Layer | Choice |
|---|---|
| Runtime | Cloudflare Workers (V8 isolate, nodejs_compat) |
| Framework | Hono 4 |
| Storage | Cloudflare D1 (SQLite at the edge) |
| Dashboard | Vanilla HTML/CSS/JS + Chart.js 4 |
| Language | TypeScript 5 (strict), no emit — Wrangler bundles the Worker |
| Tooling | Wrangler |
expense-tracker/
├── src/
│ ├── index.ts # Hono app: upload + all read endpoints, query helpers
│ ├── parsers.ts # format detection, statement parsers, statement-meta/EMI parser
│ └── pipeline.ts # date/merchant normalization, dedup hash, categorization
├── public/
│ ├── index.html # dashboard markup (Overview / Balances / Monthly / Yearly / Transactions / Imports)
│ ├── app.js # dashboard logic + Chart.js rendering
│ └── style.css # styles
├── migrations/
│ ├── 0001_init.sql # imports, transactions, category_rules, column_mappings + seed rules
│ ├── 0002_statements.sql # statements (balances) + loans (EMIs)
│ ├── 0003_accounts.sql # accounts registry + account_key/type/balance_after on transactions
│ └── 0004_savings_categories.sql # transfer/CC-payment/ATM/investment category rules
├── samples/ # synthetic statements for testing the pipeline (see samples/README.md)
├── wrangler.jsonc # Worker + D1 + static-assets config
├── tsconfig.json
├── package.json
├── AGENTS.md # conventions & constraints for contributors and AI agents
└── CONTRIBUTING.md
All tables live in D1 (SQLite). See migrations/ for the authoritative schema.
| Table | Purpose |
|---|---|
imports |
One row per upload — filename, format, counts, status. Powers the audit trail/rollback. |
transactions |
The permanent ledger. UNIQUE(txn_hash) enforces dedup at the DB level. |
accounts |
Registry of every account (account_key, account_type, label, institution, last4). |
category_rules |
Regex → category, priority-ordered, editable at runtime. |
statements |
Per-card, per-cycle balance snapshot (authoritative for "what do I owe"). |
loans |
Per-statement EMI/loan snapshot, for paydown tracking. |
column_mappings |
Reserved for future custom CSV profiles. |
Key concepts:
account_key(e.g.Card_6081,Savings_7464) is the universal discriminator that joins transactions to the account registry.account_typeiscredit_card(a liability — balance is what you owe) orsavings(an asset — balance is what you have).- Spend vs cash flow. "Spend" = debits only, and money-movement categories
(
Transfers,Credit Card Payment,ATM/Cash,Investments) are excluded by default so a ₹2L transfer doesn't dwarf real spending and a card-bill payment isn't double-counted. PassincludeTransfers=1to fold them back in. "Cash flow" counts both directions across all categories. - Balances are sourced, not summed. Credit-card balances come from the statement's
authoritative Account Summary (
statementstable), not from summing transactions. Savings balances come from the per-row runningbalance_after. - Dedup hash =
SHA-256(account | timestamp | description | amount | direction). Same-day distinct transactions survive; re-uploads collapse to no-ops.
Prerequisites: Node.js ≥ 18, npm, and a (free) Cloudflare account. Wrangler is installed as a dev dependency — no global install needed.
git clone https://github.com/Lalit-Patil-07/expense-tracker.git
cd expense-tracker
npm install
npm run db:create # creates a D1 database and prints its database_id
# paste that id into wrangler.jsonc → d1_databases[0].database_id
npm run db:migrate:local # applies all migrations to the LOCAL D1
npm run dev # http://localhost:8787Open http://localhost:8787, then upload a file from samples/ to see the dashboard
populate. npm run typecheck type-checks the Worker without emitting.
npx wrangler login # one-time browser auth
npm run db:create # once, if you haven't already — paste the id into wrangler.jsonc
npm run db:migrate:remote # create the tables on the real (remote) D1
npm run deploy # ship the Worker + dashboardYour dashboard goes live at https://<your-worker>.<your-subdomain>.workers.dev.
wrangler.jsoncships with a"<your-d1-database-id>"placeholder. Replace it with the id fromnpm run db:createbefore deploying. (A D1database_idis not a secret — access still requires your Cloudflare account login — but each fork uses its own database.)
The dashboard has six views: Overview (all-time), Balances (net worth, card balances, due alerts, EMIs), Monthly, Yearly, Transactions (searchable/filterable/paginated), and Imports (audit trail + rollback).
Monthly workflow:
- Download your bank/card statement (CSV or the HDFC statement export).
- Dashboard → + Upload statement → pick the file.
- The pipeline parses, cleans, deduplicates, categorizes, and (for card statements) records the balance snapshot and any EMIs. The dashboard refreshes. New data is added to history — prior months are never altered.
For savings/bank exports that carry no account number inside the file, the account identity is derived from the filename (see below), so keep the bank's original filename.
Format is auto-detected on upload (see parseStatement in src/parsers.ts):
| Format | Looks like | Account identity |
|---|---|---|
hdfc_pipe_statement |
HDFC credit-card export, `~ | ~`-delimited, with an Account Summary |
hdfc_savings_csv |
HDFC savings CSV: Date, Narration, …, Debit, Credit, Closing Balance |
derived from the filename (e.g. Acct_Statement_XXXXXXXX7464_….csv → Savings_7464) |
generic_csv |
Any CSV with Date + Description + Amount (or Debit/Credit) |
a Card/Account column, if present |
All endpoints are under /api. Read endpoints accept optional filters; unknown/absent filters
mean "all". accounts is a comma-separated list of account_keys; includeTransfers=1 opts
money-movement categories back into spend.
| Method & path | Description |
|---|---|
POST /api/upload |
Ingest a statement (multipart file, or raw body + ?filename=). |
GET /api/imports |
Upload audit trail. |
DELETE /api/imports/:id |
Roll back one import (deletes only that import's transactions). |
GET /api/transactions |
Filterable/searchable/paginated ledger (from,to,category,account,accounts,merchant,q,limit,offset). |
GET /api/summary/overview |
All-time KPIs, category/account/month breakdowns, top merchants. |
GET /api/summary/monthly?year=&month= |
Daily spend, categories, totals, by-account, cash flow for a month. |
GET /api/summary/yearly?year= |
By-month, categories, totals, by-account, cash flow for a year. |
GET /api/categories |
Category rules (regex, priority, enabled). |
POST /api/categories/rules |
Add a rule {category, pattern, priority?} — no redeploy needed. |
GET /api/accounts |
Account registry + per-account txn counts and date spans. |
GET /api/accounts/overview |
Net position: assets (savings), liabilities (card dues), net worth. |
GET /api/balances/overview |
Per-card current/previous balances, utilisation, due dates, history. |
GET /api/statements |
Raw statement history (all cards). |
GET /api/loans |
Latest snapshot of each active EMI/loan. |
- New bank/statement format — add a
tryParseYourBank(text, opts): ParseResult | nullinsrc/parsers.tsand register it in theparseStatementattempts array (order matters: more-specific parsers first). The clean/dedupe/categorize pipeline is unchanged. - New category —
POST /api/categories/ruleswith{category, pattern, priority}, or add a migration seedingcategory_rules. Rules are regex, matched case-insensitively against the uppercased description, lowestpriorityfirst. Takes effect on the next upload. - New account type — the model keys everything on
account_key+account_type, so a new type (wallet, loan account, brokerage) is an additive migration + a parser that tags rows with the new type, not a rewrite.
See AGENTS.md for the detailed conventions and invariants behind each of these.
npm run dev # local Worker + dashboard at http://localhost:8787
npm run typecheck # tsc --noEmit — run before every commit- Make changes, then verify against
samples/in the local dashboard (never against real/prod data). - Local D1 (
.wrangler/, gitignored) is disposable — roll back test uploads from the Imports view. - Branch, commit with a clear message, and open a PR. See CONTRIBUTING.md.
- No duplicates — every transaction's hash is unique in the DB; re-uploading a statement (or an overlapping month) is a no-op for rows already present.
- No accidental loss — uploads only ever
INSERT OR IGNORE. Nothing is overwritten or deleted by a normal upload. The only delete path is an explicit per-import rollback. - Audit trail — every upload is logged in
imports(filename, timestamp, rows parsed/inserted, duplicates, errors) even if later rolled back.
Contributions are welcome — see CONTRIBUTING.md for setup, conventions, and the PR process, and AGENTS.md for architecture, invariants, and the hard Cloudflare Workers runtime constraint.
Licensed under the GNU General Public License v3.0 or later — see LICENSE. Copyright (C) 2026 Lalit Patil.