Architecture overview
One Next.js application, one Postgres database, four external services. This page is the map; the rest of the manual is the turn-by-turn.
The pieces
| Piece | Technology | Role |
|---|---|---|
| App | Next.js 15 (App Router, React 19, TypeScript) on Vercel | Web UI, API routes, webhooks, cron endpoints — one deployable. |
| Database | Supabase Postgres via Drizzle ORM | All business data. Migrations are committed SQL files. |
| Auth | Supabase Auth | Magic-link email and Google sign-in. |
| File storage | Supabase Storage, private receipts bucket | Receipt files. Browsers upload directly via signed URLs; views use short-lived signed URLs. |
| Payments | Stripe (platform account, hosted Checkout) | One checkout session per invoice; payment confirmed by webhook. |
| Resend | Invoice sends, payment confirmations, reminders, payment-claim notifications. | |
| Receipt extraction | Anthropic API (vision, structured tool output) | Reads merchant/date/total from receipt images and PDFs. |
The service layer, and how tenancy actually works
All business logic lives in a single service layer of plain async TypeScript functions. Three thin adapters call into it — server components for reads, server actions for form mutations, and REST route handlers under /api/v1 (the future mobile surface). None of the adapters contain business logic.
Every service function takes a context carrying the database handle, the user id, and the business id, and reaches the database only through a scoped query helper (scopedDb) that injects the business_id filter into every query. This is the point that matters operationally: the service layer is the sole runtime tenancy guard. The app connects to Postgres with the Supabase service-role key, which bypasses row-level security entirely — RLS provides zero protection on the app's own queries. RLS policies are still written for every table, but they're load-bearing only on paths that use anon/JWT keys (direct-to-storage uploads today, a native app tomorrow). The honest version of this story is in Security posture.
The correctness core
Three rules keep the books right, and they're enforced in one place (a single aggregation module every report imports) rather than re-implemented per screen:
- Transfers are excluded from every report — moving your own money, including credit-card payments, is never income or expense.
- Refunds net within their category — a supplies refund reduces the Supplies line, not revenue.
- Money is integer cents (bigint) everywhere; formatting happens only at render. No floats, ever.
Paid invoices book two transactions: gross income, plus a separate Stripe-fee expense. Schedule C exports write immutable snapshots, and edits to exported years warn and hit the audit log.
Background work: sweeps, not queues
There's no queue infrastructure. All async work — receipt extraction, the invoice send sequence, overdue marking and reminders — is resumable from database state. Work runs opportunistically right after a response is sent, but durability comes from a cron-driven sweep endpoint that re-drives anything pending or stuck. If the opportunistic run dies mid-flight, the sweep finishes the job. Details in Operations.
Development mode vs production
Every external dependency has a local stand-in, selected by environment variables, so the full app runs with zero credentials:
| Dependency | Production | Local development |
|---|---|---|
| Database | Supabase Postgres (DATABASE_DRIVER=postgres) | PGlite, an embedded Postgres, persisted under .data/pglite (DATABASE_DRIVER=pglite) |
| Auth | Supabase Auth | AUTH_MODE=dev: one-click sign-in as a fixed dev user. Hard-disabled when NODE_ENV=production — it cannot ship. |
| Storage | Supabase Storage (STORAGE_MODE=supabase) | Local filesystem under .data/storage (STORAGE_MODE=local) |
| Stripe / Resend / Anthropic | Real clients, activated by the presence of their API keys | Deterministic in-memory fakes, activated automatically when the keys are absent |