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.

FrameworkNext.js 16 · App Router · React 19Server Components by default; client islands only where interaction demands it.
HostingVercel (region lhr1)Edge auth middleware, serverless route handlers, image optimization.
DataSupabase PostgresRow-Level Security on every table; SQL views for spend roll-ups.
AuthSupabase Auth (SSR, cookie)Email + Google/Apple OAuth; session refreshed in middleware.
StorageSupabase Storage (private)Receipts/documents behind short-lived signed URLs.
AIAnthropic ClaudeSonnet for receipts, Haiku for items/documents; every call cost-logged.
NativeCapacitorThe same web app wrapped for iOS/Android, static-exported.

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:

  1. 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.
  2. 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.
  3. 03Extract. extractFromImage() sends the image to Claude with a task-specific prompt. The model returns structured JSON: vendor, date, line items, suggested rooms.
  4. 04Validate. Output is sanitised server-side — types coerced, amounts bounded, future dates rejected — before it is ever shown or stored.
  5. 05Reconcile. A deterministic matcher links each line to any wishlist item the user already pinned, marking it purchased and updating the room budget.
  6. 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.