Engineering write-up
How Tally is built.
Tally is a renovation cost tracker — but the interesting part is the plumbing: a single Next.js codebase that serves a marketing site, a multi-tenant web app, and a native shell, with an AI pipeline turning photographs of receipts into structured, tax-ready records. Here's how the pieces fit.
The stack
One codebase, three surfaces
Marketing, the app, and the native build all ship from the same repository. A build flag (BUILD_TARGET=capacitor) swaps the Vercel SSR config for a static export when wrapping the app with Capacitor — the React code is identical.
Request lifecycle
From camera to ledger
The core loop is “photograph a receipt → get a verified, room-tagged expense.” That single tap fans out across the whole stack:
- 01Capture. The browser camera grabs a frame. Client-side canvas re-encodes it to an 800px JPEG, stripping EXIF (location) metadata before anything leaves the device.
- 02Gate. The upload hits a route handler that checks the user is a project member, validates image magic-bytes (not just the MIME header), and enforces a per-account scan fair-use cap + LLM spend ceiling.
- 03Extract. extractFromImage() sends the image to Claude with a task-specific prompt. The model returns structured JSON: vendor, date, line items, suggested rooms.
- 04Validate. Output is sanitised server-side — types coerced, amounts bounded, future dates rejected — before it is ever shown or stored.
- 05Reconcile. A deterministic matcher links each line to any wishlist item the user already pinned, marking it purchased and updating the room budget.
- 06Persist. The expense, its line items, and the original receipt image (in private storage) are written under the project — all behind Row-Level Security.
Multi-tenancy
Security that lives in the database
Every table carries a project_id and the same Row-Level Security quartet: reads gated by is_project_member(), writes by is_project_editor(). Tenancy flows through a project_members join with owner / editor / viewerroles. The upshot: even if a query is wrong, Postgres refuses to return another tenant's rows — authorization is enforced one layer below the application.
Two Supabase clients make the boundary explicit: a request-scoped client that respects RLS for everything user-facing, and a service-role client that bypasses it, used only in a handful of audited server actions (storage signing, account deletion). The UI also gates affordances by role, so RLS is defence-in-depth, not the only line.
The AI layer
One extraction path, many tasks
All model calls go through a single function, extractFromImage(images, { task }). New capabilities are added as tasks — never as a forked call path — so logging, validation, and cost controls apply uniformly. Each task declares its model:
receipt→ Claude Sonnet (line-item accuracy matters most)pin/classify→ Claude Haiku (fast, cheap, high volume)document→ Haiku triage of quotes / warranties / manuals, with a deterministic regex backstop
Every call is written to an llm_calls table — tokens, latency, cost, status — which powers an admin /debug dashboard showing spend and failure rates per task. Cost is a first-class, observable metric, not an afterthought.
File handling
Private by default
Receipts and documents live in a privateSupabase bucket. Nothing is ever served from a public URL. Instead a proxy route checks ownership, mints a short-lived signed URL, and caches the redirect — so an image is only ever reachable by someone who can already see the expense it belongs to. PDFs take a separate validated path (MIME allowlist + magic-byte check + size cap), since the image-only EXIF-stripping pipeline can't process them.
Trade-offs
A few decisions worth defending
- Auth in middleware, not per-page. A single edge middleware refreshes the session and redirects unauthenticated traffic, so no page has to remember to check. Public routes are an explicit allowlist.
- Seeding in app code, not the database. New-project defaults used to live in a Postgres trigger; it silently drifted from the app and won or lost depending on the call path. Moving it into the create action made it testable and single-sourced.
- Cost ceiling before billing. The scan cap + LLM spend ceiling shipped before paid plans — the guardrail that protects unit economics is more urgent than the thing that monetises it.
- Server Components first. Data fetching and formatting stay on the server; client bundles are small islands. The marketing site ships almost no JavaScript.