⚖️ TrueTrade
MVP implementation plan

Backend, frontend & security for the condo pilot

A concrete build plan for the one-condo MVP: the architecture, the ONVO Pay endpoints behind each flow, the 24-hour escrow design, the data model, the API surface, and a security plan built around handling PII, card and banking data. Start simple, iterate. Ends with a second-pass audit.

The shape of it

Architecture at a glance

We keep the existing React/Vite frontend and add a thin, stateless backend on the infrastructure the parked scaffold already chose: Cloudflare Pages Functions + Neon Postgres, with ONVO Pay as the money rail and the source of truth for anything financial. The browser never talks to our database directly and never sees a raw card number.

flowchart LR
  UI["React SPA
(Vite, existing)"]:::cl FN["Pages Functions
/api/* (new)"]:::sys CR["Scheduled Worker (cron)
reconcile · alerts"]:::sys DB[("Neon Postgres")]:::db R2[("R2 — photos")]:::db ONVO["ONVO Pay API"]:::ext UI -->|"REST + cookie session"| FN UI -.->|"tokenize card
publishable key"| ONVO FN -->|"parametrized SQL"| DB FN -->|"secret key"| ONVO ONVO -.->|"webhooks
X-Webhook-Secret"| FN CR -->|"reconcile"| FN FN --> R2 classDef cl fill:#EAF6F2,stroke:#0F7458,color:#08382C; classDef sys fill:#ffffff,stroke:#1C8F73,color:#1B2A28; classDef db fill:#EBE2D3,stroke:#6B7B77,color:#1B2A28; classDef ext fill:#EFE9FB,stroke:#7C5CBF,color:#3f2a73;

What we reuse

  • The whole React/Vite/Tailwind frontend + the 25 existing screens.
  • The domain model in types.ts as the basis for the DB schema.
  • The scaffold's infra pattern: Pages Functions, Neon, functions/_lib/{db,env}.ts, PAYMENTS_MODE=simulated|live flag, webhook-events dedup table.
  • The white-label building entry (/b/:slug) for the condo.

What's new

  • A real backend: auth, API routes, Postgres as source of truth (Zustand drops to a UI cache, no more localStorage persistence of business data).
  • Swap Stripe → ONVO in the payments layer.
  • Immediate-capture + refund-window payments, intermediated comms, firm-quote/scope-confirm, real photo uploads (R2), and a scheduled Worker for reconciliation.
The money rail

ONVO endpoints behind each flow

Mapped from the ONVO API docs (OpenAPI v2.0, base https://api.onvopay.com/v1; amounts in céntimos, currency CRC). Note two big shapes: marketplace pro accounts are created in the ONVO Dashboard, not via API, and there is no payout/transfer/held-balance endpoint — settlement is automatic.

FlowONVO endpoint(s) / objectNotes
Onboard a pro for payoutsDashboard only → Account ID (ma…) + onboarding linkNo API. Pro completes hosted onboarding (incl. IBAN), link expires 7 days. No "ready" webhook — track operationally.
Save customer cardPOST /customers · POST /payment-methods (type card, publishable key, client-side)We store only the returned paymentMethodId + last4/brand. 3DS via requires_action → redirect.
Charge the job (immediate)POST /payment-intents (onBehalfOf:<ma…>) → POST /payment-intents/{id}/confirmCaptures the saved card immediately; pro is settled by ONVO. Fee model: customer pays job + processing (pass-through); pro pays a tiered %. ⚠️ ONVO's marketplace commission is a flat % set per-pro in the Dashboard (no tiered/per-call field) — for the pilot's narrow band we calibrate it to the first tier (~9% at ₡30k); true tiering is a refinement. The 24h window is app-tracked.
Dispute in windowPOST /refunds (paymentIntentId + amount)Admin-adjudicated within 24h. Refunds the captured charge; clawback against the pro's ONVO balance (confirm split-reversal with ONVO).
Cheap one-off (SINPE)POST /payment-methods (type mobile_number) → confirm → processingpayment-intent.succeededCustomer-push; reconcile via webhook. Can't auto-charge → not for recurring.
Recurring cadence (deferred)POST /products · POST /prices (recurring) · POST /subscriptionsDeferred for the pilot (H2) — cadence is a per-visit on-session charge for now. Off-session subscriptions added later.
Auth & webhooks: Bearer API keys (onvo_live_secret_… server-side, onvo_live_publishable_… client-side). Webhooks carry a plain shared secret in X-Webhook-Secret (not HMAC) and ONVO has no idempotency keys — both shape the security design below.
The chosen design

The 24-hour window — immediate capture + refund

ONVO has no native escrow, and it doesn't document a card-auth hold window or a settlement delay we could lean on. So for the MVP we capture the charge immediately (via the onBehalfOf marketplace split) at scope-confirmation, and the 24-hour customer-protection window becomes a refund window rather than a held authorization.

✅ Decided: go with immediate capture + refund for the pilot — it's far simpler, depends on no unverified ONVO behaviour, keeps ONVO the regulated fund-holder, and pays pros faster (what informal earners want). A true payout hold (pro doesn't see money for 24h) is a later refinement, only if ONVO confirms a ≥24h auth window or a settlement delay. The tenant's experience — pay at start, 24h to flag a problem — is identical either way.
stateDiagram-v2
  [*] --> captured: scope confirmed → tenant charged (onBehalfOf), pro settled
  captured --> refund_window: work begins (24h protection window)
  refund_window --> final: +24h, no dispute → settled, window closes
  refund_window --> disputed: tenant flags an issue in-window
  disputed --> final: admin resolves for pro
  disputed --> refunded: admin resolves for tenant → POST /refunds
  final --> [*]
  refunded --> [*]
      
Capture at start · 24h refund window · admin-adjudicated disputes → refund. The pro is settled by ONVO on its normal cadence.

Why we chose it for the pilot

  • No dependency on unverified ONVO behaviour (auth-hold window, manual+onBehalfOf, settlement delay).
  • Drops the manual-capture state machine, the +24h capture cron, and the "auth expired / capture failed" recovery — three of the four Critical audit findings.
  • ONVO stays the fund-holder (no money-transmission exposure), and pros are paid promptly.

The trade-off we accept

  • Clawback risk: money has moved, so a refund pulls from the pro's ONVO balance — or from us if they're already settled. With a few trusted condo pros this is small and recoverable by hand.
  • Disputes are admin-adjudicated, not auto-refunded (the service was rendered) — a deliberate fraud guard.
  • "Pro waits 24h" is relaxed — the protection is a refund promise, not a held fund. Flagged as the refinement to revisit at scale.
Future refinement (post-pilot): if ONVO confirms a guaranteed ≥24h auth window and the manual+onBehalfOf combo, switch to manual-capture so the pro's money is genuinely held for the window (a stronger guarantee). Until then, immediate capture + refund is the build target. The open questions are in Confirm with ONVO.
Source of truth

Data model (Neon Postgres)

The domain model in types.ts becomes real tables. ONVO stays the source of truth for money; our DB is a projection updated by API responses + webhooks. Banking data is never stored here — it lives in ONVO.

TableStatusKey columns (beyond ids/timestamps)
usersNewemail, phone, role (tenant|pro|admin), org_id
sessions / magic_linksNewuser_id, token_hash, expires_at, used_at
customers, jobs, estimates, connections, reviews, organizations, homesPortMirror types.ts; jobs.source (in_platform|google|admin_manual), jobs.org_id
prosPort +onvo_account_id (ma…), onvo_onboarding_status, payout_ready (bool)no IBAN
customer_payment_methodsNewcustomer_id, onvo_customer_id, onvo_payment_method_id, brand, last4, is_default
connection_quotesNewconnection_id, firm_quote, quote_locked_at, scope_confirmed_at, reprice_amount, reprice_photo, status, trip_fee
message_threads / messagesNewconnection_id; sender_user_id, body, created_at (parties + admin only)
paymentsPort +connection_id, onvo_payment_intent_id, state (captured|refund_window|refunded|disputed), amount_cents, currency, refund_window_until, on_behalf_of
disputesNewconnection_id, raised_by, reason, resolution (void|refund|release), decided_by
recurring_plans / recurring_occurrencesPort +onvo_subscription_id, onvo_price_id
webhook_eventsReuseonvo_event_dedup_key, type, processed_at (idempotency)
idempotency_actionsNewaction_key (unique), result — guards our outbound ONVO calls
audit_logNewactor_user_id, action, resource, pii_accessed, ip, created_at
Backend

API surface (Cloudflare Pages Functions)

Thin REST handlers under functions/api/*, each: authenticate the session → authorize the caller is a party to the resource → validate input (zod) → act → audit. Shared libs: _lib/{db,onvo,auth,webhook,env}.ts.

Method · pathWhoPurpose
POST /api/auth/request-otp · /verify-otp · /logoutpublicWhatsApp OTP (6-digit code); sets session cookie.
GET /api/meanyCurrent user + role.
POST /api/jobs · GET /api/jobs/:idtenantCreate from intake; read own job.
POST /api/admin/jobs/:id/sourceadminIn-platform match (live); Google/manual stubbed.
POST /api/pro/estimatesproSubmit free initial estimate.
POST /api/jobs/:id/selecttenantSelect pro → create connection.
POST /api/connections/:id/acceptproAccept connection → open thread.
GET/POST /api/connections/:id/messagespartiesIntermediated comms thread (authz: both + admin).
POST /api/connections/:id/quote · /quote/acceptpro/tenantFirm quote pre-dispatch → tenant locks it.
POST /api/connections/:id/scope-confirmproOn-site scope match → triggers authorize.
POST /api/customers/:id/payment-methodstenantStore onvo_payment_method_id (tokenized client-side).
POST /api/connections/:id/chargeserverCreate intent (onBehalfOf) + confirm — captures immediately on the saved card. Opens the 24h refund window.
POST /api/connections/:id/disputetenantRaise issue in window → freeze (void on resolve).
POST /api/connections/:id/review · /cadencepartiesTwo-sided review; mutual-positive gate → subscription.
POST /api/internal/reconcilecronDaily ONVO↔Neon reconciliation; flag mismatches to admin. Auth via internal token.
POST /api/webhooks/onvoONVOVerify secret → re-fetch object → dedup → apply.
The scheduled Worker (a small companion Worker with a crons trigger — Pages Functions can't self-schedule) runs the daily reconciliation and the dead-man's-switch alert. With immediate capture there's no +24h capture job — a payment is final once now > refund_window_until and undisputed.
Frontend

Frontend changes

Keep React/Vite/Tailwind/Router. The big shift is data: Zustand stops being the persisted source of truth and becomes a UI cache hydrated from the API. Add auth, a typed API client, real uploads, and ONVO's client-side card tokenization.

Cross-cutting

  • API client: typed fetch wrapper, cookies, error handling; replaces direct store mutations.
  • Auth: passwordless OTP login behind a channel adapteremail for the web launch, WhatsApp as a drop-in fast-follow. Session context, role-based route guards (tenant/pro/admin), building scope.
  • Notifications: in-app for the web launch (+ optional email nudge); WhatsApp notifications are a fast-follow. The comms thread always stays in-app so it remains intermediated.
  • Card capture: ONVO web SDK / client-side POST /payment-methods with the publishable key — raw card data never hits our code; handle the 3DS redirect. The charge runs at scope-confirm, so a decline/3DS-fail surfaces before work — gate "pro starts" on a successful capture and let the tenant retry / add a card.
  • Uploads: real photos → R2 via presigned URLs (replaces SVG placeholders).
  • Env: VITE_ONVO_PUBLISHABLE_KEY, VITE_API_BASE.

New / changed screens

  • Tenant: add card-on-file setup, firm-quote accept, scope-confirm, payment + 24h-window state, in-app comms thread, review + cadence opt-in.
  • Pro: ONVO onboarding hand-off, firm-quote entry, on-site scope confirm + reprice (photo), payout/earnings status, comms thread.
  • Admin: sourcing (in-platform match + manual add), payment/capture monitor, dispute mediation, thread oversight, link pro ↔ ONVO account.
  • Reuse intake, compare, building portal, reviews, recurring screens largely as-is.
Non-negotiable

Security plan (PII · cards · banking)

We touch the three most sensitive data classes — personal data, card data, bank data — so security is designed in, not bolted on. The guiding principle: hold as little sensitive data as possible, and let ONVO hold the rest.

1 · Card & banking data — stay out of scope

2 · Secrets

3 · Webhooks & idempotency (ONVO is weak here — compensate)

4 · AuthN / AuthZ / data isolation

5 · Data protection & operations

🔐 Net: the only sensitive data we store is contact PII + comms; cards and bank details are delegated to ONVO. That's the single biggest risk reducer in the plan.
Order of work

Build sequence

Each phase is independently demoable. Money comes only after the loop works end-to-end without it.

P0 Foundations

  • Neon schema + migrations · Pages Functions skeleton + _lib · passwordless OTP auth (email channel) + sessions · typed API client · move core entities off localStorage onto the API · seed the condo + its pros.

P1 Core loop, no money

  • Intake → in-platform sourcing → estimate → select → accept → intermediated comms thread. (Mostly porting existing screens to the API.)

P2 Quote & scope

  • Firm quote (pre-dispatch) → tenant locks it → on-site scope confirmation (+ evidenced reprice). No charge yet.

P3 Payments & escrow

  • Pro ONVO onboarding hand-off · client-side card tokenization · immediate capture on scope-confirm (onBehalfOf) · 24h refund window · dispute = admin-adjudicated refund · webhook handler (verify → refetch → dedup, claim-before-call idempotency). (Reconciliation + alerts land in P5.)

P4 Reviews → cadence

  • Two-sided reviews + mutual-positive opt-in gate. Recurring billing deferred per the audit (H2): cadence is captured as intent + a per-visit on-session charge for the pilot; ONVO subscriptions are added only once the core flow is proven.

P5 Hardening & dry-run

  • Audit log · rate limiting · error handling · reconciliation job (DB ↔ ONVO) · security review · a full dry-run with a test card before real tenants.
Blockers to clear first

Confirm with ONVO before P3

The payments phase rests on a few things the public docs don't answer. Get these in writing before building.

For the chosen design (immediate capture + refund)

  1. When a marketplace (onBehalfOf) charge is refunded, how is the clawback split between our commission and the pro's net — and what happens if the pro's balance is already zero?
  2. How fast does seller IBAN settlement actually happen (instant vs batched)? Determines how much real clawback risk we carry in the 24h window. (Docs are silent — must ask.)
  3. Can our informal condo pros actually pass ONVO's KYC onboarding (tax/bank docs)? Validate one real pro before building anything.

For the future refinement (true 24h hold)

  1. Can captureMethod:"manual" be combined with onBehalfOf (split at capture)?
  2. What's the maximum card-authorization hold window — does it safely cover 24h+?
  3. Is there genuinely no held-payout/balance product, even in private beta?
Second pass

Audit of this plan

An independent security + payments review of the plan above, then the fixes folded back in. Ranked by severity.

Run by an independent security + payments reviewer instructed to attack the plan. The blunt headline: the escrow is architected on ONVO payment behaviour nobody has confirmed exists, and the fallback quietly turns us into an unlicensed fund-holder. Findings, ranked:
✅ Triage complete. Every finding below carries a status — Resolved by a design choice · Accepted as a pilot trade-off · Tracked as a build/legal task. The single immediate-capture decision resolved C1, C2, C4, H2 and M1 at once. What remains is tracked build controls plus one accepted trade-off (H5 — refund clawback).

Critical

Critical

C1 · The escrow depends on an unverified card-auth hold window

Card authorizations aren't a 24h-guaranteed product — the issuer can reverse a hold well before any processor maximum. A capture at +24h may simply find no auth left, after the pro already did the work.

Fix → Confirm the guaranteed window in writing. If it isn't ≥24h, don't hold the auth — pivot to immediate capture + refund-based disputes (see synthesis).

Resolved Closed by the immediate-capture decision — we charge at scope-confirmation, before work begins, so there's no auth to expire and no "worked, then money gone." Returns only if we build the manual-capture "true hold" refinement, where it's tracked as a Confirm-with-ONVO precondition.
Critical

C2 · "Capture fails at +24h" has no recovery — and it's the common case, not an edge

You can't silently re-authorize a dead/expired auth. At +24h a real fraction of holds will be gone, leaving "work done, money gone" with no state for it.

Fix → Explicit requires_capture → capture_attempted → captured | capture_failed. On failure, try a fresh off-session charge on the saved card; if that fails, a collections state + admin alert. Decide now who eats a failed collection (put it in the pro agreement).

Resolved Immediate capture moves the charge before work begins, so a decline surfaces up front and the pro just doesn't start — the "worked, then charge failed" case can't occur. Tracked Build task: handle a scope-confirm decline / on-session 3DS in the FE, and gate "pro starts work" on a successful capture.
Critical

C3 · No ONVO idempotency + scheduled capture = double-charge exposure

POST /capture, ONVO captures, the response drops, the Worker retries → second capture. Our action_key mitigation fails if we write it after the call.

Fix → Claim the action atomically before the ONVO call (INSERT … ON CONFLICT DO NOTHING, proceed only if you won the insert), and always re-fetch the intent by id to observe true state before any mutation. The sweep must re-confirm requires_capture per fresh fetch.

Tracked Intrinsic to ONVO (no idempotency keys) — the design can't remove it, so it's a build control on the immediate charge and on refunds. Acceptance criterion: the action_key row is claimed (INSERT … ON CONFLICT DO NOTHING) before the ONVO call, never after; refetch-by-id before any retry. Specified in Security §3 / P3. Any slip-through is auto-refunded.
Critical

C4 · Cron is a single point of failure with no delivery guarantee

Cloudflare scheduled events can be skipped/delayed with no built-in retry/alert. A missed run = pros unpaid + auths silently expiring.

Fix → Make the sweep catch-up-based (capture everything with capture_due_at <= now(), not "this hour"), run it every 5–15 min, add a dead-man's-switch alert if no successful sweep in N minutes, and a manual "run now" admin endpoint.

Resolved The money-critical capture cron is gone (immediate capture; window closes by clock, computed lazily), and subscriptions are deferred — so no cron sits on the money path. Tracked The one remaining cron (daily reconciliation) is non-critical and self-healing — build it catch-up-based off a watermark, with a heartbeat alert.

High

High

H1 · Webhook ordering can corrupt state

Re-fetch gives current truth, not ordering. If any branch acts on the event type rather than the fetched status, a stale event corrupts state.

Fix → Treat every webhook as only "go reconcile object X." Derive state from the fetched object, apply with a guard that never moves state backward (monotonic).

Tracked Webhooks still arrive, but the decision shrinks the blast radius — the payment lifecycle is now small and forward-only (captured → refund_window → final + terminals). Acceptance criteria: (1) state is always derived from a fresh fetch, never the event type; (2) a monotonic guard rejects any backward transition along the defined lattice. Extends Security §3.
High

H2 · Off-session recurring renewals will hit SCA/3DS declines

An off-session renewal where the issuer demands a challenge can't show one → hard decline. Recurring revenue silently fails.

Fix → For the pilot, defer subscriptions entirely — collect per-job on-session with 3DS completed. Set up the saved card for future off-session use on the first charge so a mandate is on file when we do add recurring.

Resolved Subscriptions are deferred (decided with the payments call) — the MVP makes no off-session charges, so this failure mode doesn't exist. Cadence = per-visit on-session charge, tenant present to complete any 3DS. Tracked When recurring is added: set the card up for off-session use on the first charge, and treat off-session declines as a first-class re-auth flow.
High

H3 · Magic-link auth pitfalls

Mail scanners prefetch links (burning single-use tokens / creating sessions on GET); no rate-limit enables enumeration/bombing; a reflected redirect param is an open-redirect/token-leak vector.

Fix → Never create a session on link GET — require a POST (button click). Rate-limit + constant-response on requests. Short expiry, invalidate prior links, strict redirect allowlist.

Resolved Switched to OTP codes (no magic link → no prefetch problem). Web launch sends the code by email; WhatsApp is a drop-in fast-follow (same flow, swap the channel). Tracked Controls: 6-digit single-use code, short TTL, limited verify attempts, rate-limited + constant-response sends, __Host- cookie. Folded into Security §4.
High

H4 · No reconciliation between ONVO (truth) and Neon (cache)

Drift is guaranteed (missed webhooks, mis-recorded captures). "Re-fetch before acting" doesn't catch silent divergence.

Fix → Daily reconciliation sweep: list ONVO objects changed in the last 24–48h, diff against local, push mismatches to an admin queue. ONVO wins every conflict.

Tracked The safety net under the whole money layer — must build. Daily ONVO→Neon diff (cursor-paginated, watermark-based, shares the C4 reconciliation cron) → admin mismatch queue; ONVO is authoritative on every conflict. Already wired as /api/internal/reconcile in the API + P5.
High

H5 · Refund clawback on a connected charge is unverified

A capture-then-refund inside the window may be unclawable if the pro's balance is already zero.

Fix → Pre-capture disputes = void the auth, never capture-then-refund. Confirm post-capture clawback mechanics + negative-balance-pro policy with ONVO before enabling refunds.

Accepted The known cost of immediate capture — every dispute is a post-capture refund (we gave up the pre-capture void). Small at pilot scale: a few trusted pros, disputes admin-adjudicated, recoverable by hand. Tracked Before enabling refunds: confirm ONVO's onBehalfOf clawback mechanics, and write the negative-balance / recovery policy into the pro agreement. This is also the strongest case for the future "true hold" refinement.

Medium

Resolved M1 — the chosen design keeps ONVO the fund-holder (no platform-held funds → no money-transmission exposure); the fallback isn't used. M3 — drop app-level column encryption as over-engineering; rely on Neon at-rest + strict access control + audit + PII offloaded to ONVO.
Tracked M2 — Ley 8968 legal checklist before the pilot opens (consent, PRODHAB registration check, retention/deletion for comms threads, cross-border disclosure). M4 — rate-limit + rotate the webhook secret, dedup-with-TTL, accept residual risk (ask ONVO re HMAC); refetch already in §3. M5 — opaque single-use nonce in the returnUrl + CSRF-protected POST finalize that re-fetches ONVO truth.

Low

Don't log the full payment-method response (L1) · log admin thread reads distinctly (L2) · __Host- cookie prefix + idle/absolute timeouts (L3) · enforce building scope in a single query choke-point (L4) · define partial-capture rounding/minimum-amount rules (L5).

Resolved L3 (already in Security §4 — __Host- + timeouts) · L5 (no partial capture under immediate capture — amount is fixed at scope-confirm; just a minimum-charge sanity check).
Tracked L1 (don't log the full payment-method response) · L2 (log admin thread reads + disclose) · L4 (single building-scope choke-point; don't over-build isolation).
Top 3 to fix before any code: (1) Resolve the ONVO unknowns in writing — manual+onBehalfOf, the guaranteed hold window, refund clawback; they decide whether escrow is even viable and whether we become a money transmitter. (2) Design the capture/charge path failure-first with idempotent claim-before-call and a catch-up sweep. (3) Make webhooks event-agnostic + add reconciliation.

Synthesis — what changes in the plan

  • Escrow pivots to immediate capture + refund-based disputes (primary). Charge via onBehalfOf at scope-confirmation; the 24h "window" becomes a refund window, not an auth-hold. This drops the unverified-auth-window dependency (C1), the capture-fail recovery problem (C2), and most of the cron risk (C4), and keeps ONVO the regulated fund-holder (M1). Manual-capture-hold is demoted to a "nice-to-have if ONVO confirms a ≥24h hold + the onBehalfOf combo."
  • Defer ONVO subscriptions (H2): collect per-job on-session with 3DS; add recurring only after the core flow is proven.
  • Adopt failure-first payments: claim-before-call idempotency (C3), catch-up sweep + dead-man's-switch (C4), explicit capture_failed → collections (C2), daily ONVO↔Neon reconciliation (H4).
  • Harden auth + webhooks: POST-to-consume magic links + rate-limit (H3); event-agnostic monotonic webhook handling + dedup/rotate (H1/M4).
  • Drop app-level column encryption (M3) and heavy multi-tenant isolation (L4) as pilot over-engineering; keep one scope choke-point. Add a Ley 8968 checklist (M2) to P5.

Net: even with the dispute window as a refund rather than a hold, the product behaviour the tenant sees is identical — but the implementation is dramatically simpler and lower-risk. Build that version.