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 togetMessages({ locale })+<NextIntlClientProvider locale messages>. This is the same manual-resolution pattern the office layout uses. noindex. The portal is private; its layout sets robotsnoindex.
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
| Layer | Office | Portal |
|---|---|---|
| Identity | Google OAuth | Google OAuth + magic link / passkey |
| Authorization gate | office role in account_roles | any non-deleted accounts row |
| Data access | service role, manually scoped | service role, scoped by account_id |
| Signup | invite-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
| Path | Role |
|---|---|
src/lib/portal/auth.ts | getPortalClient() / requirePortalClient() — the one place the client scope is resolved |
src/lib/portal/reservations.ts | listClientReservations() / getClientReservation() — all reads, filtered by clientId |
src/lib/portal/locale.ts | resolvePortalLocale() — cookie → Accept-Language → default |
src/lib/portal/format.ts | pure date / date-range formatting (es-CO / en-US) |
src/app/portal/layout.tsx | 3rd root layout: light theme, noindex, fonts, intl provider |
src/app/portal/page.tsx | dashboard — current + past reservation cards |
src/app/portal/reservations/[id]/page.tsx | one reservation, ownership-scoped, with line items |
src/lib/portal/provision.ts | ensureClientAccount() — 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.ts | code exchange + destinationForSession() routing |
src/app/portal/actions.ts | signOut() |
src/components/portal/ui.tsx | PortalShell, 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]
| Factor | Stripe | Wompi (Bancolombia) |
|---|---|---|
| Local methods (PSE, Nequi, Bancolombia, cash via Efecty) | limited / not first-class in CO | native — the methods Colombians actually use |
| Card auth holds (auth-then-capture for deposits) | first-class, well-documented | supported but thinner docs/tooling |
| COP settlement | via cross-border; FX + payout friction | native COP settlement locally |
| Developer experience / SDK maturity | excellent | good, smaller ecosystem |
| Webhooks / idempotency primitives | mature | present, less battle-tested |
| Dispute / chargeback tooling | mature | local-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