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:
parent
9ec22e2241
commit
90af3a91ec
3 changed files with 216 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
Loading…
Add table
Reference in a new issue