Docs.

Client portal (/portal)

architecture/client-portal.md

A signed-in surface where a rental client sees their own reservations — current and past — and (in a deferred second phase) pays a deposit to turn a quote into a held reservation. Read-only today; the payment flow is designed here but not built. The provider split is decided — Stripe for USD, Wompi for COP — but no integration is wired live yet (see the last section).

Where it lives

The portal is a top-level route group (src/app/portal/**), a sibling of the public [locale] group and the staff office group — three independent root layouts, no shared app/layout.tsx.

graph TD
    R["request"] --> P["src/proxy.ts"]
    P -->|"host = qr.studio.chat"| QR["QR resolver → 307"]
    P -->|"/office*"| OFF["office group<br/>(dark, EN-only,<br/>office role)"]
    P -->|"/portal*"| POR["portal group<br/>(light, EN/ES,<br/>accounts row)"]
    P -->|"else"| INTL["next-intl → [locale] group<br/>(public marketing)"]

    style POR fill:#1e40af,color:#fff

proxy.ts short-circuits /portal past both the launch gate (a client must reach their account whether or not the marketing site is gated) and next-intl locale routing (the portal resolves its own locale from the cookie; it is not under [locale] and has no URL locale prefix).

Why a separate root layout

  • Theme & chrome. The portal is light-themed like the public site but has no marketing Header/Footer — it gets a minimal account shell (PortalShell, wordmark + sign-out). The office is dark; the public site has full chrome. Three layouts keep each concern isolated.
  • Localization boundary. Public pages get locale from the [locale] segment; the portal has no such segment, so its layout resolves locale manually (resolvePortalLocale()) and passes it explicitly to getMessages({ locale }) + <NextIntlClientProvider locale messages>. This is the same manual-resolution pattern the office layout uses.
  • noindex. The portal is private; its layout sets robots noindex.

Auth model

The portal mirrors the office's two-layer model — Supabase for identity, RLS-bypassing service role for data — but swaps the authorization rule. Sign-in is the single /auth/sign-in door shared with the office: Google OAuth for everyone, plus a magic link (and optional passkey) as a client convenience. There's no separate portal login form anymore.

sequenceDiagram
    participant U as Client
    participant L as /auth/sign-in
    participant SB as Supabase Auth
    participant CB as /auth/callback
    participant DB as accounts (service role)

    U->>L: Continue with Google (or request a magic link)
    L->>SB: signInWithOAuth / signInWithOtp
    SB-->>U: OAuth redirect / magic link
    U->>CB: return with code
    CB->>SB: exchangeCodeForSession
    CB->>DB: destinationForSession() — office role?
    alt office role
        CB-->>U: 302 /office
    else client (open signup)
        CB->>DB: ensureClientAccount() — create on first sign-in
        CB-->>U: 302 /portal
    end
LayerOfficePortal
IdentityGoogle OAuthGoogle OAuth + magic link / passkey
Authorization gateoffice role in account_rolesany non-deleted accounts row
Data accessservice role, manually scopedservice role, scoped by account_id
Signupinvite-only (a role is granted)open — first sign-in self-provisions

The authorization gate is a client record, not the JWT. Every verified session resolves to a client: getPortalClient() looks up a non-deleted accounts row by email and, if there isn't one yet, self-provisions it (ensureClientAccount, idempotent) — open signup. Only a session with no verified email (or an archived account) fails, and requirePortalClient() then bounces to /auth/sign-in. The first-sign-in account creation happens in the callback; the lookup here is the self-heal for any session that predates it.

Why service-role-with-scoping instead of RLS

Every rental table is RLS deny-all for authenticated (and anon). So even if a client's JWT leaked, PostgREST returns nothing — there is no RLS policy that would ever expose a row to a client token. Rather than write and maintain a parallel set of client-facing RLS policies (which would have to re-encode "this reservation belongs to the client whose email is this JWT's email"), the portal reads through the service role and applies the client_id filter in one place: src/lib/portal/reservations.ts. The client scope is a single, testable choke point.

That choke point is the security-critical surface, so it has a dedicated regression guard: tests/e2e/portal-access.e2e.test.ts asserts that listClientReservations and getClientReservation never return another client's rows (by list or by direct id), that the current/past split matches ACTIVE_STATUSES, and that a foreign or non-existent id yields null (no existence oracle).

File map

PathRole
src/lib/portal/auth.tsgetPortalClient() / requirePortalClient() — the one place the client scope is resolved
src/lib/portal/reservations.tslistClientReservations() / getClientReservation() — all reads, filtered by clientId
src/lib/portal/locale.tsresolvePortalLocale() — cookie → Accept-Language → default
src/lib/portal/format.tspure date / date-range formatting (es-CO / en-US)
src/app/portal/layout.tsx3rd root layout: light theme, noindex, fonts, intl provider
src/app/portal/page.tsxdashboard — current + past reservation cards
src/app/portal/reservations/[id]/page.tsxone reservation, ownership-scoped, with line items
src/lib/portal/provision.tsensureClientAccount() — open-signup self-provision (also audits a genuine first sign-up)
src/app/auth/sign-in/*the shared sign-in door (Google + client magic link / passkey)
src/app/auth/callback/route.tscode exchange + destinationForSession() routing
src/app/portal/actions.tssignOut()
src/components/portal/ui.tsxPortalShell, StatusBadge (light-surface tones)

Localization strings live under the portal namespace in locales/{en,es}.json (login, dashboard, reservation, status). Spanish is fully localized, including reservation-status labels (consulta, cotizada, retenida, reservada, entregada, en devolución, en inspección, liquidada, cerrada, cancelada, en disputa).

Deferred: contract generation and e-signature

Designed, not built (owner-decided, 2026-06). The rental contract is part of the client's path, so it belongs here even though no code exists for it yet:

  • The contract is generated as a Google Doc — not a custom PDF pipeline and not DocuSign/HelloSign.
  • It is emailed to the client and to studio.chat and signed via Google Docs' built-in e-signature feature. A working email is therefore required for every renter — this is the same verified contact email that, with a verified phone, ID, and the 100% deposit, forms the entire rental gate (there is no vetting, no insurance, and no COI).
  • The system tracks execution — contract status plus signed/sent timestamps surface in the office — and posts Slack notifications on the contract lifecycle.
  • The protection model is deposit-only: the 100% replacement-value deposit covers loss or damage. There is no insurance, COI, or damage waiver behind the contract.

Today the office stores a manually uploaded signed PDF in reservations.contract_url; the generation, e-sign, status tracking, and Slack steps above are all not built. Nothing in the read-only portal surfaces a contract yet.

Deferred: pay-a-deposit-to-create-a-reservation

The mandate's portal item includes "pay a deposit to create a reservation." That half is designed here but intentionally not built — there are no live payment-provider keys in hand yet. What is decided is the provider split: Stripe handles USD, Wompi handles COP, the two coexisting per-row via the payment_provider enum (see "Provider split" below). The integration itself stays deferred.

The data model is already shaped for it — payments has payment_provider ∈ {stripe, wompi, cash, transfer} and payment_kind ∈ {deposit_hold, deposit_capture, deposit_release, balance_charge, refund, damage_charge}, and addPayment() writes rows + an audit trail. What's missing is the provider integration and the quote→held transition triggered by a successful deposit hold.

Intended flow (when a provider is wired)

sequenceDiagram
    participant C as Client
    participant P as /portal
    participant PR as Provider (Stripe/Wompi)
    participant WH as Webhook
    participant DB as reservations / payments

    C->>P: review quote, "hold with deposit"
    P->>PR: create payment intent (deposit amount)
    PR-->>C: hosted card form (3DS / PSE)
    C->>PR: pay
    PR->>WH: payment_intent.succeeded
    WH->>DB: addPayment(deposit_hold) + transition quoted→held
    WH-->>P: (client polls / redirected) reservation now held

The transition must be driven by the webhook, not the browser redirect — the redirect can be lost and must never be the source of truth for "money moved." quoted → held is already a legal edge in RESERVATION_TRANSITIONS.

Provider split — Stripe for USD, Wompi for COP

Decided: the portal will run both providers, split by the currency the client pays in — Wompi for COP, Stripe for USD. Colombian clients paying in COP go through Wompi (PSE / Nequi / Bancolombia, native COP settlement); international / card-first clients paying in USD go through Stripe. The payment_provider enum already lets both coexist per-row, so a reservation's deposit row records whichever provider actually moved the money. This split is the decided policy; neither integration is wired live yet — no keys, no webhook routes, no code committed to either provider.

The reasoning behind the split, for context:

quadrantChart
    title Why the split — provider fit by payment context
    x-axis "Weaker local payment UX" --> "Stronger local payment UX"
    y-axis "Heavier integration" --> "Lighter integration"
    quadrant-1 "Light + local (ideal)"
    quadrant-2 "Heavy but local"
    quadrant-3 "Heavy + foreign-feeling"
    quadrant-4 "Light but foreign-feeling"
    "Wompi": [0.82, 0.62]
    "Stripe": [0.40, 0.70]
FactorStripeWompi (Bancolombia)
Local methods (PSE, Nequi, Bancolombia, cash via Efecty)limited / not first-class in COnative — the methods Colombians actually use
Card auth holds (auth-then-capture for deposits)first-class, well-documentedsupported but thinner docs/tooling
COP settlementvia cross-border; FX + payout frictionnative COP settlement locally
Developer experience / SDK maturityexcellentgood, smaller ecosystem
Webhooks / idempotency primitivesmaturepresent, less battle-tested
Dispute / chargeback toolingmaturelocal-bank-grade

Wompi carries the COP deposit because the people paying in pesos are in Colombia and PSE/Nequi/Bancolombia is what converts there — a deposit flow that forces a foreign card experience would leak conversions. Stripe carries the USD deposit for international / card-first clients and brings the richer auth-hold tooling. Routing by currency (currency_display) means each client meets the provider that fits how they actually pay, with no per-row guesswork. The split is decided; the integration is not built — no code commits to either until keys exist.

What "build phase 2" entails (checklist for later)

  • Provider accounts + test keys for both (WOMPI_* for COP, STRIPE_* for USD) in env
  • POST /api/portal/deposits (or server action) to create the intent, routed by currency (Wompi for COP, Stripe for USD)
  • Webhook route per provider, each with signature verification + idempotency on the event id
  • On succeeded: addPayment(deposit_hold) + transitionReservation(quoted→held)
  • Portal UI: deposit amount on the quote, "hold with deposit" CTA, success/poll state
  • e2e: webhook-driven quote→held with a mocked provider event