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 -> locked-down security definer functions only:
get_quote_by_token,accept_quote_by_token,decline_quote_by_token(quote link);get_invoice_by_token,choose_invoice_payment_by_token(invoice link), plus one endpoint that creates the Stripe Checkout session for card payment. - Stripe -> webhook (signed) ->
record_stripe_payment->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).- Offline sync depends on
updated_atfor incremental pulls anddeleted_atfor tombstones. New owner-owned app tables must include both from the start, with anupdated_attrigger. Hard deletes are not a valid app-level delete shape unless a table is explicitly outside the offline mirror. - 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. rebatescarry one row per quote or invoice (XORquote_id/invoice_id, unique per parent).amount_penceis authoritative;percent_bpis metadata for percentage rebates. Reason is a fixed enum (loyalty,volume,materials,goodwill,other) plus optional free-text note. Snapshotted into parenttotal_penceat send time. Use rebates when the reduction has a reason worth reporting on;update_invoice_draft_totalremains for pure total corrections (materials estimate typo, etc.).- Rebate + CIS ordering (deferred to RM-6, fixing the rule now to avoid ambiguity later): rebate applies first, then CIS on top.
total_pence = subtotal_pence - rebate.amount_pence - cis_deduction_pence. CIS is still computed against the labour portion of the underlying quote items, not the rebated total — the rebate is a discount at the invoice level, not a per-line unit-price change. Reporting: gross revenue = subtotal, discounts = rebate, taxable turnover = subtotal - rebate.
Tables: profiles, rate_cards, clients, quotes, quote_items, invoices, payments, rebates, expenses, reminders. Full DDL: supabase/migrations/20260609000000_init.sql + follow-up migrations.
State machine
Section titled “State machine”Quote: draft -> sent -> accepted | declined (expired derived, voided owner-initiated).
Invoice: draft -> sent -> paid | void. Payment is decoupled from acceptance. Accepting a quote takes no money and creates no invoice — it is a plain terms agreement. When the work is done the tradesperson raises an invoice off the accepted quote(s); the client then chooses how to pay (card or cash) on the invoice link.
Rules:
- Each transition has exactly one writer: draft -> sent (app, RLS); sent -> accepted / declined (anon definer functions
accept_quote_by_token/decline_quote_by_token, no payment side effects); invoice creation (create_invoice_draft, app, owner-only); draft -> sent (send_invoice, app); sent -> paid (Stripe webhook via service role, orrecord_manual_paymentfor cash); void (app). Rebates use draft-only writers on both parents:apply_quote_rebate/remove_quote_rebate/apply_invoice_rebate/remove_invoice_rebate;send_quoteandsend_invoicere-snapshot totals including the rebate. One writer per transition means no races and trivial audit. - The client’s payment-method choice lives on the invoice (
invoices.payment_method), set bychoose_invoice_payment_by_tokenat pay time — never on the quote.cardredirects to Stripe Checkout;cashflags intent so the tradesperson knows to collect and record it. 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 card-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”Quote link (/q/:token, get_quote_by_token):
- Open quote -> accept or decline. Neither takes payment.
- Accepted -> confirmation: “you’ll be invoiced when the work’s done”. Shows
paidlater if the eventual invoice settles. - Declined -> acknowledgement + nudge to ask for a revised quote.
- Expired -> one-tap “ask for updated quote” re-engagement, inserting a notification and pushing to the tradesperson.
Invoice link (/i/:token, get_invoice_by_token):
- Sent, outstanding -> choose Pay by card (Stripe Checkout) or Pay in cash (
choose_invoice_payment_by_token('cash'), then “hand cash over” guidance). - Paid -> confirmation.
Payment routes
Section titled “Payment routes”Payment happens at the invoice stage, after the work is done. At that point several routes are possible:
- Card (Stripe Checkout) — client taps “Pay by card” on the invoice link; 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 edits the draft invoice total before sending. The new total is what the client sees and pays.
- Cash (or bank transfer) payment — client chooses “Pay in cash” / pays offline; tradesperson records a manual payment row (
method = 'manual', nullstripe_event_id) viarecord_manual_payment. No Stripe Checkout session is created. - Combination — part cash / part card, 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.