207 lines
10 KiB
Markdown
207 lines
10 KiB
Markdown
# 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_responses` row is inserted; the latest non-null selection is canonical. No hard lock after first selection.
|
||
- `payment_methods_config` is 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_responses` row is written.
|
||
- `providerNotes` (deposit warnings, recall-window advisories) are stripped from the pre-selection document response (see `sanitizeDocument()`). They appear on the confirmed page.
|
||
|
||
---
|
||
|
||
## `payment_methods_config` JSONB schema
|
||
|
||
Stored in `documents.payment_methods_config`. Authored by the provider in `provider-portal`.
|
||
|
||
```typescript
|
||
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**:
|
||
1. Validate `cas_session` cookie; resolve `client_id` and `provider_id`.
|
||
2. Resolve document by `(client_id, provider_id, doc_year, slug)`. Verify `status` is not `'expired'`. If expired → `{ error: 'document_expired' }`.
|
||
3. Hash the request IP (SHA-256). Do not store raw IP.
|
||
4. `INSERT INTO document_responses (document_id, option_code, ip_hash, created_at)`.
|
||
5. Fetch payment methods: `SELECT * FROM payment_methods WHERE id = ANY($visibleMethodIds) AND provider_id=$pid AND visibility='public'` — or all public methods if `visibleMethodIds` is empty.
|
||
6. Merge `payment_methods_config.sections` ordering onto the fetched methods. If no sections config, derive default: `kind='crypto'` first, then `kind IN ('cash','prepaid')`, then `kind='bank_transfer'`.
|
||
7. Apply `cryptoSurchargePercent` annotation to crypto methods.
|
||
8. Include `providerNotes` in the response (this is the only endpoint where they are un-stripped).
|
||
9. 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:
|
||
|
||
1. **Selected option summary** — `optionCode` + the matching option text from `body_markdown` (SPA parses the option code from the document content).
|
||
|
||
2. **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.
|
||
|
||
3. **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
|
||
|
||
1. **Pre-selection** — client has not tapped any option. Payment method section is hidden. CTA: option cards.
|
||
2. **Loading** — POST in flight. Option card shows spinner; others are dimmed.
|
||
3. **Confirmed** — option selected; confirmed page rendered. Payment method cards visible.
|
||
4. **Document expired** — `status='expired'`. SPA renders expired-state screen: "This document has expired. Contact the provider."
|
||
5. **Already responded** — client revisits the document after selecting. SPA shows their previous selection highlighted; "View your deposit instructions" CTA back to confirmed page.
|
||
6. **Reload on confirmed page** (no SPA state) — SPA calls `/confirmed-state` to 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`](./client-area-architecture.md) — `document_responses` schema, `sanitizeDocument()` contract, `payment_methods` table
|
||
- [`client-document-types.contract.md`](./client-document-types.contract.md) — `quote` doctype, status transitions, presentation JSONB
|
||
- [`client-onboarding.flow.md`](./client-onboarding.flow.md) — how the client reaches the quote page
|
||
- [`client-area.brief.md`](./client-area.brief.md) — feature overview; what Client Area is and is not
|