docs(client-area): 📝 Update client-area docs to reflect new document resolution flow and quote confirmation states in architecture, flow, and contract documentation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 19:58:47 -07:00
parent 9ec22e2241
commit 90af3a91ec
3 changed files with 216 additions and 5 deletions

View file

@ -200,8 +200,9 @@ All routes under `/client-area/*`. The NestJS module is `ClientAreaModule`.
|---|---|---|---|
| `GET` | `/client-area/documents` | Session cookie | List documents for the authenticated client. Returns safe projection (no `body_markdown`). Ordered by `created_at DESC`. |
| `GET` | `/client-area/documents/:year/:slug` | Session cookie | Full document payload. Resolves by `(client_id, doctype=any, doc_year=year, slug)`. |
| `GET` | `/client-area/documents/by-number/:doctype/:docNo` | Session cookie | Resolve document by `(client_id, doctype, doc_no)`. Used by magic-link `action=openQuote&userDocNo=1`. |
| `GET` | `/client-area/documents/by-number/:doctype/:docNo` | Session cookie | Resolve document by `(client_id, doctype, doc_no)`. Used by magic-link `action=openDocument&doctype=quote&userDocNo=1`. |
| `POST` | `/client-area/documents/:year/:slug/respond` | Session cookie | Record a `document_responses` row. Body: `{ optionCode, paymentMethodId? }`. Returns the updated document + payment method details for the confirmed page. |
| `GET` | `/client-area/documents/:year/:slug/confirmed-state` | Session cookie | Returns the most recent `document_responses` row for the document + full payment methods payload. Used when the client reloads the confirmed page without SPA state. |
### Health

View file

@ -82,9 +82,12 @@ No existing routes break when a new doctype is added. Existing `document_respons
`doc_no` is a per-(client_id, doctype) integer sequence starting at 1. It is a human-readable ordinal — "your first quote", "your second invoice" — not a global ID.
**Assignment at INSERT** (in a serializable transaction):
**Assignment at INSERT** (using an advisory lock to serialize concurrent inserts for the same client/doctype pair):
```sql
-- Within a single transaction:
SELECT pg_advisory_xact_lock(hashtext($client_id::text || $doctype));
INSERT INTO documents (client_id, provider_id, doctype, doc_no, ...)
SELECT
$client_id,
@ -93,11 +96,11 @@ SELECT
COALESCE(MAX(doc_no), 0) + 1,
...
FROM documents
WHERE client_id = $client_id AND doctype = $doctype
-- FOR UPDATE on the client row to serialize concurrent inserts for the same client
;
WHERE client_id = $client_id AND doctype = $doctype;
```
The `UNIQUE (client_id, doctype, doc_no)` constraint is the hard backstop — if two concurrent inserts race past the advisory lock (bug), the second INSERT fails with a unique violation rather than silently producing a duplicate `doc_no`.
**Uniqueness enforcement**: `CONSTRAINT documents_client_doctype_docno_unique UNIQUE (client_id, doctype, doc_no)`.
**Cross-doctype independence**: a client can have `quote doc_no=1` and `invoice doc_no=1` simultaneously. `doc_no` is not global across doctypes. Uniqueness is within `(client_id, doctype)`.

View file

@ -0,0 +1,207 @@
# 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 4045 — `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