Skip to content

Architecture, Schema & Cost Model

Status: agreed for MVP · Last updated: 10 June 2026

Scope: trade services app: quoting, invoicing, expenses, and tax estimate. Decisions here are settled unless superseded; rationale lives in the build guide.

LayerChoiceNotes
Tradesperson appReact Native + Expo (TypeScript)EAS builds, OTA updates. Native needed for camera, push, share sheet
Client-facing pagesPlain web app (React/Next.js)Quote view, accept, pay. No login, link-based access only
Backend logicPython Azure FunctionsPDF generation, receipt extraction, Stripe webhooks, chase scheduler
Database / auth / storageSupabase (Postgres)App talks to Supabase directly for auth + CRUD under RLS
PaymentsStripe Payment LinksGoCardless / open banking later for large invoices
Receipt extractionClaude API (Haiku 4.5), visionConfirm-not-type UX; no OCR pipeline built in-house
Chase messagesEmail (Resend/SES) -> SMS (Twilio) escalationWhatsApp Business API deferred
Push notificationsExpo pushFree

Principle: Functions exist only for work needing server-side trust or compute: secrets, signatures, rendering. Plain CRUD goes app -> Supabase under RLS. The MVP backend is roughly five functions.

  • Expo app -> Supabase directly: auth, quotes, items, expenses CRUD.
  • Expo app -> Functions for PDF/OCR actions.
  • Client web page -> two locked-down security definer functions only: get_quote_by_token, accept_quote_by_token, plus one Functions endpoint to create the deposit Stripe link.
  • Stripe -> Functions webhook (signed) -> payments insert -> invoice status.
  • Nightly timer trigger -> overdue query -> chase messages.
  • Serverless + Postgres: always connect via Supabase pooler (Supavisor) or HTTP interface; never raw connections per invocation.
  • All money is integer pence. Format to pounds at the edge only. Stripe speaks minor units.
  • profiles.id = auth.users.id, created by signup trigger. RLS everywhere is profile_id = auth.uid() (child tables via exists against parent).
  • Quote totals are snapshotted on send. quote_items are source of truth while drafting; on send, freeze total_pence and the rendered PDF. Accepted prices never drift.
  • public_token (32 hex chars, unique) is the client’s only access path. No anon RLS policies on tables; anon role can execute the two definer functions and nothing else.
  • payments.stripe_event_id unique = webhook idempotency. Manual payments (cash/bank transfer) are payment rows with method = 'manual' and a null event id. The constraint must be nullable-unique; see follow-up migration.
  • cis_deduction_pence is a stored amount, not a flag: 20% registered, 30% unregistered, 0% gross. CIS applies to labour only, never materials; per-line kind enables the split.
  • rate_cards model multiple rates (trades/workers) per business: label, day/hour unit, pence. quote_items.rate_card_id is a convenience reference; unit_price_pence remains the snapshot. Soft-delete via active. Rates are not team members; multi-user is out of scope.
  • expenses.category is locked to self-assessment categories from day one: materials, tools, vehicle, phone, insurance, other. The accountant export is then a single query.
  • reminders logs every chase: invoice, channel, sent_at. This prevents double-sends and shows history in the UI.
  • Job photos: photo_paths jsonb on quotes pointing at Supabase storage.

Tables: profiles, rate_cards, clients, quotes, quote_items, invoices, payments, expenses, reminders. Full DDL: supabase/migrations/20260609000000_init.sql.

Quote: draft -> sent -> accepted (also declined future, expired derived).

Invoice: draft -> sent -> paid | void. The invoice machine runs twice per job: deposit (auto-created on accept) and final (created by tradesperson tapping “job done”).

Rules:

  1. Each transition has exactly one writer: draft -> sent (app, RLS); sent -> accepted (anon definer function); invoice creation (Functions endpoint holding Stripe key); sent -> paid (webhook via service role); void (app). One writer per transition means no races and trivial audit.
  2. expired is derived, not stored. accept_quote_by_token refuses past valid_until, which is the only place correctness matters. Display layers report expired when valid_until < current_date. Reviving a quote = updating valid_until.
  3. overdue is derived (status = 'sent' and due_date < now()), never stored. Partial index idx_invoices_overdue serves the nightly chaser.
  4. Paid and void are terminal. Nothing un-accepts a quote: buyer’s remorse before payment is a voided invoice plus a conversation, keeping accepted_at clean as terms-acceptance evidence.
  5. Webhook is the only source of truth for paid. Stripe redirect pages render optimistically but never write state; redirects can be forged, webhooks are signed. Success page may poll briefly for the payment row.

The page calls get_quote_by_token and routes:

  1. Open quote -> accept.
  2. Accepted, deposit due -> Stripe link.
  3. Deposit paid -> confirmation + what-happens-next.
  4. Expired -> one-tap “ask for updated quote” re-engagement, inserting a notification and pushing to the tradesperson.

At the point of payment, several routes are possible:

  1. Stripe Payment Link — amounts match the quoted price; client pays via the Stripe Payment Link (see Stack). Webhook is the source of truth (payments row with signed stripe_event_id).
  2. Material cost adjustment — material costs changed between quote and completion (prices rose, scope changed, etc.); the tradesperson updates the final invoice before reissuing the Stripe Payment Link. The new total is shown to the client before they pay.
  3. Cash (or bank transfer) payment — client pays offline; tradesperson records a manual payment row (method = 'manual', null stripe_event_id). No Stripe Payment Link is issued.
  4. Combination — part cash / part Stripe Payment Link, or a cost adjustment settled partly offline. Each settlement leg is its own payments row; the invoice moves to paid once the sum of confirmed payment rows covers total_pence.

June 2026 prices.

Marginal cost per active user/month, assuming roughly 15 quotes, 25 invoices, 50 receipt scans, and 12 SMS:

ComponentPer user / month
Claude API receipt extraction (Haiku 4.5, $1/$5 per MTok)~£0.10
Twilio UK SMS (~$0.046/msg)~£0.45
Email~£0.01
Azure Functions~£0 (free grant covers thousands of users)
Supabase share ($25/mo flat)Pennies, shrinks with scale
Total marginal~£0.55-0.70

Fixed floor is roughly £30-35/month: Supabase Pro, Apple Developer, Twilio number, domain, ICO registration. Three paying users cover it.

Per receipt scan: image roughly 1,050 tokens (resized 768 x 1024) + roughly 200 prompt + roughly 80 output = about 0.13p. Resize client-side to roughly 1024px long edge. Unresized photos hit the API cap around 1,600 tokens, roughly 0.18p, and upload slower. Caching/batch is not worth it at these sizes; model choice is an accuracy decision, not cost. Sonnet is roughly 0.4p/receipt.

Stripe fees, roughly 1.5% + 20p for UK cards, come out of the tradesperson’s payout, never our margin - standard across competitors. Open banking for large invoices is a “keep more of your money” feature.

SMS is the only behaviour-scaled cost: chase ladder is email -> email -> SMS, with a cap on automatic SMS per invoice.

Before client page ships:

  1. Extend get_quote_by_token to return deposit invoice status + Stripe link; page routes states 2/3 off the invoice.
  2. Make get_quote_by_token report expired when valid_until has passed. Stored status stays sent.
  3. Loosen payments.stripe_event_id to nullable + unique for manual payments.