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

PieceTechnologyRole
AppNext.js 15 (App Router, React 19, TypeScript) on VercelWeb UI, API routes, webhooks, cron endpoints — one deployable.
DatabaseSupabase Postgres via Drizzle ORMAll business data. Migrations are committed SQL files.
AuthSupabase AuthMagic-link email and Google sign-in.
File storageSupabase Storage, private receipts bucketReceipt files. Browsers upload directly via signed URLs; views use short-lived signed URLs.
PaymentsStripe (platform account, hosted Checkout)One checkout session per invoice; payment confirmed by webhook.
EmailResendInvoice sends, payment confirmations, reminders, payment-claim notifications.
Receipt extractionAnthropic 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:

DependencyProductionLocal development
DatabaseSupabase Postgres (DATABASE_DRIVER=postgres)PGlite, an embedded Postgres, persisted under .data/pglite (DATABASE_DRIVER=pglite)
AuthSupabase AuthAUTH_MODE=dev: one-click sign-in as a fixed dev user. Hard-disabled when NODE_ENV=production — it cannot ship.
StorageSupabase Storage (STORAGE_MODE=supabase)Local filesystem under .data/storage (STORAGE_MODE=local)
Stripe / Resend / AnthropicReal clients, activated by the presence of their API keysDeterministic in-memory fakes, activated automatically when the keys are absent
Because no production credentials have been provisioned yet, the live Supabase, Stripe, Resend, and Anthropic code paths are code-complete but unverified — every test and local run exercises the fakes. OPEN-ISSUES.md in the repository tracks each pending verification. Treat first contact with each real service as a validation exercise, not a formality.