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.
| Layer | Choice | Notes |
|---|---|---|
| Tradesperson app | React Native + Expo (TypeScript) | EAS builds, OTA updates. Native needed for camera, push, share sheet |
| Client-facing pages | Plain web app (React/Next.js) | Quote view, accept, pay. No login, link-based access only |
| Backend logic | Python Azure Functions | PDF generation, receipt extraction, Stripe webhooks, chase scheduler |
| Database / auth / storage | Supabase (Postgres) | App talks to Supabase directly for auth + CRUD under RLS |
| Payments | Stripe Payment Links | GoCardless / open banking later for large invoices |
| Receipt extraction | Claude API (Haiku 4.5), vision | Confirm-not-type UX; no OCR pipeline built in-house |
| Chase messages | Email (Resend/SES) -> SMS (Twilio) escalation | WhatsApp Business API deferred |
| Push notifications | Expo push | Free |
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.
Architecture
Section titled “Architecture”- 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) ->
paymentsinsert -> 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.
Schema conventions
Section titled “Schema conventions”- 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 isprofile_id = auth.uid()(child tables viaexistsagainst parent).- Quote totals are snapshotted on send.
quote_itemsare source of truth while drafting; on send, freezetotal_penceand 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_idunique = webhook idempotency. Manual payments (cash/bank transfer) are payment rows withmethod = 'manual'and a null event id. The constraint must be nullable-unique; see follow-up migration.cis_deduction_penceis a stored amount, not a flag: 20% registered, 30% unregistered, 0% gross. CIS applies to labour only, never materials; per-linekindenables the split.rate_cardsmodel multiple rates (trades/workers) per business: label, day/hour unit, pence.quote_items.rate_card_idis a convenience reference;unit_price_penceremains the snapshot. Soft-delete viaactive. Rates are not team members; multi-user is out of scope.expenses.categoryis locked to self-assessment categories from day one: materials, tools, vehicle, phone, insurance, other. The accountant export is then a single query.reminderslogs every chase: invoice, channel,sent_at. This prevents double-sends and shows history in the UI.- Job photos:
photo_paths jsonbon quotes pointing at Supabase storage.
Tables: profiles, rate_cards, clients, quotes, quote_items, invoices, payments, expenses, reminders. Full DDL: supabase/migrations/20260609000000_init.sql.
State machine
Section titled “State machine”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:
- 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.
expiredis derived, not stored.accept_quote_by_tokenrefuses pastvalid_until, which is the only place correctness matters. Display layers report expired whenvalid_until < current_date. Reviving a quote = updatingvalid_until.overdueis derived (status = 'sent' and due_date < now()), never stored. Partial indexidx_invoices_overdueserves the nightly chaser.- Paid and void are terminal. Nothing un-accepts a quote: buyer’s remorse before payment is a voided invoice plus a conversation, keeping
accepted_atclean as terms-acceptance evidence. - 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.
Client page states
Section titled “Client page states”The page calls get_quote_by_token and routes:
- Open quote -> accept.
- Accepted, deposit due -> Stripe link.
- Deposit paid -> confirmation + what-happens-next.
- Expired -> one-tap “ask for updated quote” re-engagement, inserting a notification and pushing to the tradesperson.
Payment routes
Section titled “Payment routes”At the point of payment, several routes are possible:
- Stripe Payment Link — amounts match the quoted price; client pays via the Stripe Payment Link (see Stack). Webhook is the source of truth (
paymentsrow with signedstripe_event_id). - 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.
- Cash (or bank transfer) payment — client pays offline; tradesperson records a manual payment row (
method = 'manual', nullstripe_event_id). No Stripe Payment Link is issued. - Combination — part cash / part Stripe Payment Link, or a cost adjustment settled partly offline. Each settlement leg is its own
paymentsrow; the invoice moves topaidonce the sum of confirmed payment rows coverstotal_pence.
Cost model
Section titled “Cost model”June 2026 prices.
Marginal cost per active user/month, assuming roughly 15 quotes, 25 invoices, 50 receipt scans, and 12 SMS:
| Component | Per user / month |
|---|---|
| Claude API receipt extraction (Haiku 4.5, $1/$5 per MTok) | ~£0.10 |
| Twilio UK SMS (~$0.046/msg) | ~£0.45 |
| ~£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.
Follow-up migration
Section titled “Follow-up migration”Before client page ships:
- Extend
get_quote_by_tokento return deposit invoice status + Stripe link; page routes states 2/3 off the invoice. - Make
get_quote_by_tokenreportexpiredwhenvalid_untilhas passed. Stored status stayssent. - Loosen
payments.stripe_event_idto nullable + unique for manual payments.