10 KiB
Flow — quote option-select and payment-rail-picker
Goal
The client reads the quote, selects one pricing option (e.g. "A1: Weekend in London · £18,500"), and lands on the confirmed page where deposit instructions are grouped by payment section. The provider receives a notification. No payment happens inside the Client Area — it surfaces the rails; the provider closes the payment out-of-band.
Constraints
- Option selection is idempotent. If the client refreshes or taps a different option, a new
document_responsesrow is inserted; the latest non-null selection is canonical. No hard lock after first selection. payment_methods_configis per-document, not per-provider. A quote can show a different rail mix than another quote from the same provider (e.g. BTC-only for an international client; cash-and-crypto for a domestic client).- Sensitive payment details (wallet addresses, bank handles) are never shown before option selection. They appear only on the confirmed page, after a
document_responsesrow is written. providerNotes(deposit warnings, recall-window advisories) are stripped from the pre-selection document response (seesanitizeDocument()). They appear on the confirmed page.
payment_methods_config JSONB schema
Stored in documents.payment_methods_config. Authored by the provider in provider-portal.
interface DocumentPaymentMethodsConfig {
// Subset of payment_methods IDs (UUIDs in v4) to display.
// If absent or empty, all of the provider's 'public' payment_methods are shown.
visibleMethodIds?: string[];
// Ordered display sections. If absent, platform-api derives a default ordering
// from payment_methods.kind: crypto → cash/prepaid → transfers.
sections?: PaymentSection[];
// Crypto cap: if the deposit amount exceeds this percentage of the total,
// do not show a second crypto rail as an alternative. Null = no cap.
cryptoCapPercent?: number | null;
// Surcharge applied to crypto rails, in percent.
// Displayed as a note on each crypto method card: "+3% for crypto".
cryptoSurchargePercent?: number | null;
// Free-text notes from the provider, shown on the confirmed page only.
// Use for: recall-window warnings, timing advisories, contact instructions.
providerNotes?: string[];
}
interface PaymentSection {
label: string; // e.g. 'Crypto', 'Cash & prepaid', 'Transfers'
methodIds: string[]; // ordered subset of visibleMethodIds for this section
}
v2 reference: lilith-platform.live/codebase/@features/vip/frontend-client/src/api.ts lines 40–45 — visibleMethodIds, sections, cryptoCapPercent, cryptoSurchargePercent, providerNotes match this schema exactly. v4 changes only methodIds from number[] to string[] (UUID FKs).
Step-by-step flow
Step 1 — Client reads the quote
Actor: client browser
Action: Document loaded at /documents/:year/:slug. SPA renders body_markdown (provider-authored content with pricing tiers). Payment method details are not yet visible.
State: documents.status = 'sent'. No document_responses row yet.
Step 2 — Client selects an option
Actor: client
Action: Client taps an option card (e.g. "A1: Weekend in London · £18,500"). SPA calls:
POST /client-area/documents/:year/:slug/respond
{ optionCode: 'A1' }
platform-api processing:
- Validate
cas_sessioncookie; resolveclient_idandprovider_id. - Resolve document by
(client_id, provider_id, doc_year, slug). Verifystatusis not'expired'. If expired →{ error: 'document_expired' }. - Hash the request IP (SHA-256). Do not store raw IP.
INSERT INTO document_responses (document_id, option_code, ip_hash, created_at).- Fetch payment methods:
SELECT * FROM payment_methods WHERE id = ANY($visibleMethodIds) AND provider_id=$pid AND visibility='public'— or all public methods ifvisibleMethodIdsis empty. - Merge
payment_methods_config.sectionsordering onto the fetched methods. If no sections config, derive default:kind='crypto'first, thenkind IN ('cash','prepaid'), thenkind='bank_transfer'. - Apply
cryptoSurchargePercentannotation to crypto methods. - Include
providerNotesin the response (this is the only endpoint where they are un-stripped). - Return
{ ok: true, document, paymentMethods: PaymentMethodsResponse }.
State change: document_responses row inserted.
Logged: response event in agent_actions (document_id, option_code, client_id, provider_id).
Step 3 — SPA navigates to confirmed page
Actor: SPA
Action: On { ok: true } response, SPA navigates to /documents/:year/:slug/confirmed. The confirmed-page route reads the response payload from SPA state (no second fetch required; the respond endpoint returned the full payload).
If the client reloads the confirmed page directly (no SPA state), SPA re-fetches the document and the latest document_responses row:
GET /client-area/documents/:year/:slug/confirmed-state
This endpoint returns the most recent document_responses row for the document + the full payment methods payload. It is identical to the respond endpoint's response, minus a new DB write.
State change: none.
Step 4 — Confirmed page renders
Actor: client browser
Action: SPA renders three zones:
-
Selected option summary —
optionCode+ the matching option text frombody_markdown(SPA parses the option code from the document content). -
Payment method cards, grouped by section:
─── Crypto ────────────────────────────────── [Bitcoin] 3FZbgi29cpjq2GjdwV8eyHuJJnkLtktZc5 Note: +3% surcharge for crypto deposits. [Monero] 47sB14udNgSPKTKxnzgDFfAEJEhGv7sZiR… ─── Cash & prepaid ────────────────────────── [CashApp] $transquinnftw ─── Transfers ─────────────────────────────── [Wise] @maison-cocotte Note: Bank rails carry a UK recall window of up to 6 weeks.Each card shows:
label,value(wallet address / handle / account),note(per-method),kind-appropriate icon. -
Provider notes (from
payment_methods_config.providerNotes), rendered as a callout block:Please send 30% deposit to confirm. Message me once sent with a screenshot. Bank transfers: be aware of the UK recall window — crypto preferred for international.
State change: none.
Step 5 — Provider receives notification
Actor: platform-api → notifier worker → provider
Action: After the document_responses INSERT in step 2, platform-api emits a domain event document.responded with { document_id, option_code, client_id, provider_id }. The notifier worker consumes this event and dispatches a push notification to the provider (iOS APNs via ai-copilot's notifier path, and/or a digest SMS to the provider's own phone via mac-sync).
Notification content (provider-visible):
Client responded to "london-first-weekend-together" — selected A1: Weekend, £18,500.
State change: notification dispatched. Provider sees it in provider-portal notification inbox.
Open question: should the document.responded event be a NestJS EventEmitter2 in-process event, or should it go through a BullMQ queue on vps-0's Redis? In-process is simpler for P0 (no Redis dependency for the Client Area); BullMQ is needed if notification dispatch must survive platform-api restarts. Recommended: BullMQ from the start; the queue is already in the infrastructure plan for the notifier worker.
Step 6 — Optional: client picks a specific payment method
Actor: client (optional; P1)
Action: Client taps one of the payment method cards to signal which rail they intend to use. SPA calls:
POST /client-area/documents/:year/:slug/respond
{ optionCode: 'A1', paymentMethodId: '<uuid>' }
Same endpoint as step 2; inserts a new document_responses row with payment_method_id set. Latest row is canonical.
State change: document_responses row with payment_method_id set.
Rationale: this is an optional signal, not a payment commitment. The provider receives a more specific notification ("selected crypto — Bitcoin"). This is a P1 feature; at P0, paymentMethodId is accepted but not required, and no additional notification is sent for the method-level selection.
States to design
- Pre-selection — client has not tapped any option. Payment method section is hidden. CTA: option cards.
- Loading — POST in flight. Option card shows spinner; others are dimmed.
- Confirmed — option selected; confirmed page rendered. Payment method cards visible.
- Document expired —
status='expired'. SPA renders expired-state screen: "This document has expired. Contact the provider." - Already responded — client revisits the document after selecting. SPA shows their previous selection highlighted; "View your deposit instructions" CTA back to confirmed page.
- Reload on confirmed page (no SPA state) — SPA calls
/confirmed-stateto restore the confirmed-page payload.
Out of scope
- Actual payment processing (no Stripe, no crypto gateway; Client Area is display-only)
- Client-initiated invoice requests
- Multi-option selection (one option per client per document)
- Provider-side confirmation of payment received (that's
provider-portal, not Client Area)
Related
client-area-architecture.md—document_responsesschema,sanitizeDocument()contract,payment_methodstableclient-document-types.contract.md—quotedoctype, status transitions, presentation JSONBclient-onboarding.flow.md— how the client reaches the quote pageclient-area.brief.md— feature overview; what Client Area is and is not