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.
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;
types.ts as the basis for the DB schema.functions/_lib/{db,env}.ts, PAYMENTS_MODE=simulated|live flag, webhook-events dedup table./b/:slug) for the condo.localStorage persistence of business data).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.
| Flow | ONVO endpoint(s) / object | Notes |
|---|---|---|
| Onboard a pro for payouts | Dashboard only → Account ID (ma…) + onboarding link | No API. Pro completes hosted onboarding (incl. IBAN), link expires 7 days. No "ready" webhook — track operationally. |
| Save customer card | POST /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}/confirm | Captures 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 window | POST /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 → processing → payment-intent.succeeded | Customer-push; reconcile via webhook. Can't auto-charge → not for recurring. |
| Recurring cadence (deferred) | POST /products · POST /prices (recurring) · POST /subscriptions | Deferred for the pilot (H2) — cadence is a per-visit on-session charge for now. Off-session subscriptions added later. |
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.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.
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 --> [*]
onBehalfOf, settlement delay).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.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.
| Table | Status | Key columns (beyond ids/timestamps) |
|---|---|---|
users | New | email, phone, role (tenant|pro|admin), org_id |
sessions / magic_links | New | user_id, token_hash, expires_at, used_at |
customers, jobs, estimates, connections, reviews, organizations, homes | Port | Mirror types.ts; jobs.source (in_platform|google|admin_manual), jobs.org_id |
pros | Port + | onvo_account_id (ma…), onvo_onboarding_status, payout_ready (bool) — no IBAN |
customer_payment_methods | New | customer_id, onvo_customer_id, onvo_payment_method_id, brand, last4, is_default |
connection_quotes | New | connection_id, firm_quote, quote_locked_at, scope_confirmed_at, reprice_amount, reprice_photo, status, trip_fee |
message_threads / messages | New | connection_id; sender_user_id, body, created_at (parties + admin only) |
payments | Port + | connection_id, onvo_payment_intent_id, state (captured|refund_window|refunded|disputed), amount_cents, currency, refund_window_until, on_behalf_of |
disputes | New | connection_id, raised_by, reason, resolution (void|refund|release), decided_by |
recurring_plans / recurring_occurrences | Port + | onvo_subscription_id, onvo_price_id |
webhook_events | Reuse | onvo_event_dedup_key, type, processed_at (idempotency) |
idempotency_actions | New | action_key (unique), result — guards our outbound ONVO calls |
audit_log | New | actor_user_id, action, resource, pii_accessed, ip, created_at |
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 · path | Who | Purpose |
|---|---|---|
POST /api/auth/request-otp · /verify-otp · /logout | public | WhatsApp OTP (6-digit code); sets session cookie. |
GET /api/me | any | Current user + role. |
POST /api/jobs · GET /api/jobs/:id | tenant | Create from intake; read own job. |
POST /api/admin/jobs/:id/source | admin | In-platform match (live); Google/manual stubbed. |
POST /api/pro/estimates | pro | Submit free initial estimate. |
POST /api/jobs/:id/select | tenant | Select pro → create connection. |
POST /api/connections/:id/accept | pro | Accept connection → open thread. |
GET/POST /api/connections/:id/messages | parties | Intermediated comms thread (authz: both + admin). |
POST /api/connections/:id/quote · /quote/accept | pro/tenant | Firm quote pre-dispatch → tenant locks it. |
POST /api/connections/:id/scope-confirm | pro | On-site scope match → triggers authorize. |
POST /api/customers/:id/payment-methods | tenant | Store onvo_payment_method_id (tokenized client-side). |
POST /api/connections/:id/charge | server | Create intent (onBehalfOf) + confirm — captures immediately on the saved card. Opens the 24h refund window. |
POST /api/connections/:id/dispute | tenant | Raise issue in window → freeze (void on resolve). |
POST /api/connections/:id/review · /cadence | parties | Two-sided review; mutual-positive gate → subscription. |
POST /api/internal/reconcile | cron | Daily ONVO↔Neon reconciliation; flag mismatches to admin. Auth via internal token. |
POST /api/webhooks/onvo | ONVO | Verify secret → re-fetch object → dedup → apply. |
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.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.
fetch wrapper, cookies, error handling; replaces direct store mutations.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.VITE_ONVO_PUBLISHABLE_KEY, VITE_API_BASE.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.
onvo_payment_method_id + last4/brand for display.DATABASE_URL, internal-cron token → Cloudflare Pages secrets (wrangler pages secret put), never in the repo or client bundle. WhatsApp Business API token added with the fast-follow.VITE_). .dev.vars stays gitignored.webhook_events dedup (with a TTL); out-of-order safe (state derived from a fresh fetch, monotonic — never moves backward). Rate-limit the endpoint and rotate the shared secret periodically — it's low-assurance, so refetch + dedup do the real work.action_key claimed before the call so a retry can't double-charge.__Host--prefixed, httpOnly + Secure + SameSite cookies with idle + absolute timeouts; CSRF tokens on state-changing requests. (Email OTP also sidesteps the magic-link prefetch problem — no link to prefetch.)Each phase is independently demoable. Money comes only after the loop works end-to-end without it.
_lib · passwordless OTP auth (email channel) + sessions · typed API client · move core entities off localStorage onto the API · seed the condo + its pros.onBehalfOf) · 24h refund window · dispute = admin-adjudicated refund · webhook handler (verify → refetch → dedup, claim-before-call idempotency). (Reconciliation + alerts land in P5.)The payments phase rests on a few things the public docs don't answer. Get these in writing before building.
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?captureMethod:"manual" be combined with onBehalfOf (split at capture)?An independent security + payments review of the plan above, then the fixes folded back in. Ranked by severity.
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).
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).
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.
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.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.
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).
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.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.
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.
__Host- cookie. Folded into Security §4.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.
/api/internal/reconcile in the API + P5.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.
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.returnUrl, not a guessable intent id on a GET.returnUrl + CSRF-protected POST finalize that re-fetches ONVO truth.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).
__Host- + timeouts) · L5 (no partial capture under immediate capture — amount is fixed at scope-confirm; just a minimum-charge sanity check).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.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."capture_failed → collections (C2), daily ONVO↔Neon reconciliation (H4).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.