Personal expense tracking with receipts and mileage.
What it does:
- Log expenses two ways: receipt-based (upload/scan an image, add date, report, category, merchant, amount) and mileage (drive route on a map — Leaflet + OSRM — with configurable per-year mileage rates)
- Receipts by email: forward a receipt to your inbox address and it's parsed (merchant/amount/category) and added automatically — see below
- Organize into reports and categories; every receipt image is stored and auto-renamed to a convention (YYYY-MM-DD_Report_Name.jpg)
- Export: PDF per report (with embedded receipt images) and a ZIP of everything
- Settings: reports, categories, mileage rates, home location for mileage routes
Stack:
- React Router v8 (framework mode) + Tailwind v4, TypeScript
- Postgres via Prisma — accounts, users, expenses, reports, categories, settings, mileage, image blobs
- Images: Postgres BYTEA (prod and dev/test) — no external store
- Deployed on Vercel + Neon (git push to main auto-deploys)
Auth & accounts (the recent work):
- Username/password login (scrypt-hashed), sessions via signed cookies
- Multi-user accounts: each account has its own data, users in the same account share everything, other accounts fully isolated
- New users join an account via an 8-char invite code (shown in Settings, regenerable); anyone can self-signup into a fresh account
- Image keys are namespaced per account so two accounts can never collide
Login is username/password (scrypt-hashed). Every expense, report, category, and setting belongs to an account; everyone in an account shares them, and accounts are fully isolated from each other.
- Sign up → creates a brand-new account (starts empty).
- Join → enter an account's invite code (Settings → Account) to share that account's data.
- The first account/user is bootstrapped from
APP_USERNAME/APP_PASSWORDwhen the database is empty.
- Track receipt expenses (date, merchant, amount, image, category, report) and mileage expenses (date, 2+ addresses, distance, amount, report).
- Mileage routes run Home → stops → Home; distance is computed via OSRM and the amount from a per-year mileage rate. Maps use Leaflet + OpenStreetMap — no API keys required.
- Incomplete expenses are highlighted so they're easy to finish.
- Paste (⌘V) or upload an image anywhere to start a new receipt.
- Export: each report as a PDF (grouped by category, with all receipt
images), or everything as a ZIP (CSV + images named
YYYY-MM-DD_REPORT_FILE.ext).
Storage is Postgres-only via Prisma (prisma/schema.prisma is the
single schema source of truth; the client is generated to
prisma/generated by pnpm build:prisma). DATABASE_URL is required at
startup (the app exits with a clear error otherwise). Receipt images live in
Postgres BYTEA (image_blobs) in prod and dev — no separate storage service.
| Data | Images |
|---|---|
accounts / users / |
Postgres BYTEA (image_blobs, |
expenses / reports / |
prod and dev) |
categories / settings / |
|
mileage / image_blobs |
All reads/writes go through app/lib/store.server.ts (→
app/lib/database.ts, Prisma queries scoped by accountId); image storage
is behind app/lib/images.server.ts (Prisma imageBlob).
Keys are images/{accountId}/... pathnames on every backend — namespaced
per account so two accounts can never collide on the same filename.
Schema changes: edit prisma/schema.prisma, then prisma migrate dev --name … locally and
pnpm db:push (or pnpm db:migrate) before deploying.
The file-era migration source (data/*.csv + data/images/* and the
pnpm migrate-data one-off) was deleted in the Jul 2026 cleanup — data now
lives in the database, and importing from Expensify happens via
scripts/import-expensify.ts. Cloning prod uses scripts/clone
(prisma/backup.sql).
Load order: real process.env (Vercel dashboard, or inline) wins; a local
.env file fills the gaps. DATABASE_URL is required; .env is gitignored. if
(!hasDatabase()) {
dev / test — local .env:
# .env (project root, gitignored)
DATABASE_URL=postgres://assaf@localhost/expense_dev # include the local user
SESSION_SECRET=… # signs the session cookie (random hex)
APP_USERNAME=… # bootstrap: first account's username (empty DB only)
APP_PASSWORD=… # bootstrap: first account's password (empty DB only)
# Receipts by email (all optional):
# RESEND_API_KEY=re_… INBOUND_EMAIL_WEBHOOK_SECRET=whsec_…
# [email protected] # forwarding + reply sender
# DEEPSEEK_API_KEY=sk-… RECEIPT_OCR_MODE=autoOn an empty database the first account + user are bootstrapped from
APP_USERNAME/APP_PASSWORD (fail-closed if missing); afterwards users are
created through the app's signup/join flow. SESSION_SECRET is always
required. APP_USERNAME/APP_PASSWORD can be removed from .env once you
have at least one user.
Tests intentionally hardcode expense_test (Postgres incl. image blobs),
ignore the local database, and reset the schema from Prisma on each run
(pnpm test:db:push in the test setup).
prod — Vercel: set env vars in the project dashboard (Settings →
Environment Variables): DATABASE_URL (Vercel Postgres / Neon pooled URL),
SESSION_SECRET,
and (only until the first user exists) APP_USERNAME / APP_PASSWORD.
Vercel injects them at runtime; .env never exists there.
Forward a receipt email to your inbox address and it's parsed and added automatically: the merchant, amount, and category are extracted, the receipt is stored as an image, and the expense date is the date of the email being forwarded. If something can't be processed, a reply email explains what happened.
How it decides what to import:
- Receipt attached as PDF/image → that attachment becomes the receipt image; text is extracted from the PDF text layer (or OCR'd) to get the merchant/amount/category.
- Receipt inline in the email (ASCII/HTML) → the email body is turned into an image and stored; the text is parsed the same way.
- Multiple attachments (e.g. a receipt + a logo or signature) → only the actual receipt is handled (heuristics + model tiebreak).
- The sender must be on the account's allowed sender list (Settings → Receipts by email); anything else gets a "sender not recognized" reply. You can add several addresses. If the same address is allowed by multiple accounts, the account that added it first claims it — removing it there falls through to the next account that allows it.
- Successful imports don't email you; incomplete ones (missing merchant, amount, …) create the expense anyway and reply listing what's missing.
- Each email is processed at most once (idempotent per email id).
Vercel has no inbound email, so receipt emails are received by Resend
(receiving = parse email → POST webhook) and parsed by DeepSeek
(deepseek-v4-flash). Replies on failure go out through Resend too.
-
Create a Resend account and add a domain (e.g.
labnotes.org) — you'll point MX/DKIM/SPF DNS records at Resend. -
Resend → Receiving: add a receiving domain and an inbound route (catch-all or
receipts@…) that POSTs tohttps://<your-app>/api/inbound-email. -
On the webhook, copy the signing secret (
whsec_…). -
Create a DeepSeek API key.
-
Set env vars (dev
.env, prod Vercel dashboard):RESEND_API_KEY=re_… # receive + send replies INBOUND_EMAIL_WEBHOOK_SECRET=whsec_… # verifies the webhook signature [email protected] # forwarding address + reply sender DEEPSEEK_API_KEY=sk-… # receipt text/OCR extraction DEEPSEEK_MODEL=deepseek-v4-flash # optional, this is the default RECEIPT_OCR_MODE=auto # auto|deepseek|tesseract (default auto)
All are optional — without them the webhook returns 503 and receipts aren't imported.
-
In the app: Settings → Receipts by email → add each address you'll forward from (you can add several), then forward a receipt to your inbound address.
Notes:
- DeepSeek vision: the hosted DeepSeek API is text-only today — image
receipts are OCR'd locally with tesseract.js (worker/fonts fetched from a
CDN at runtime). Set
RECEIPT_OCR_MODE=deepseekif/when the hosted model accepts images (it tries vision first and falls back automatically onauto). - Scanned PDFs (no text layer) are rasterized and OCR'd; the first pages become the stored receipt image.
- HTML receipts are stored as a rendered text image (monospace receipt sheet) — no headless browser needed.
- Forwarding as attachment (.eml) — the receipt nested inside the .eml is not unpacked; use normal inline forwarding (Gmail/iOS quote the original in the body).
- Webhook processing runs up to 60s (Vercel
maxDuration) — enough for attachment download + OCR + extraction.
Prerequisites: Postgres running locally (brew services start postgresql@18).
createdb expense_dev # once
pnpm install
# create .env with the local values above
pnpm db:push # create the schema from prisma/schema.prisma
pnpm dev # reads .envRunning the server without DATABASE_URL exits immediately with a clear
error — there is no file-based fallback.
pnpm check # prisma generate + typegen + format + lint + typecheck
pnpm build # production build (build:prisma runs first)
pnpm start # serve the production build (port 3000)
pnpm test # resets expense_test from Prisma and runs the suiteNode 24+ and pnpm 11+ (developed/tested on Node 26).
Vercel (recommended): the app deploys with Vercel's zero-config React
Router support — no preset needed. (@vercel/react-router's vercelPreset()
is still pinned to React Router v7 as of this writing — track
vercel/vercel#16730; the
zero-config path builds one SSR function that serves every route.)
-
Push to GitHub (already configured:
origin→assaf/expense). -
In Vercel: Add New → Project → Import
assaf/expense. Framework is auto-detected as React Router;vercel.jsonpins the build command. -
Set env vars in the project (Settings → Environment Variables):
DATABASE_URL— Vercel Postgres / Neon pooled URL. Tables are created automatically on first request.- Node 24+ (
engines; the project runs on Node 26); pick Node 26 in project settings if Vercel doesn't match automatically.
-
One-time data import is done — the CSV source under
data/was deleted in the Jul 2026 cleanup (data verified in the database: 306 expenses, 247 images). Import from Expensify now goes throughscripts/import-expensify.ts; cloning prod locally usesscripts/clone. -
Deploy. Test the app is behind Deployment Protection or basic auth — the app has no built-in login (single-user personal tool).
./scripts/deploy # check + tests + deploy
./scripts/deploy --skip-testsVercel has its own environment management (dashboard) — set DATABASE_URL
there directly (all images are stored in the database, so no other storage
env is needed).
Uses free OpenStreetMap services (Nominatim for geocoding, OSRM for routing, OSM raster tiles for the map). Rate-limited but fine for personal use. If OSRM is unavailable, distance falls back to straight-line (marked "approx.").