From 90af3a91ecf069d81569e4eced18d3a0519561be Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 19:58:47 -0700 Subject: [PATCH] =?UTF-8?q?docs(client-area):=20=F0=9F=93=9D=20Update=20cl?= =?UTF-8?q?ient-area=20docs=20to=20reflect=20new=20document=20resolution?= =?UTF-8?q?=20flow=20and=20quote=20confirmation=20states=20in=20architectu?= =?UTF-8?q?re,=20flow,=20and=20contract=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../docs/client-area-architecture.md | 3 +- .../docs/client-document-types.contract.md | 11 +- .../docs/client-quote-confirm.flow.md | 207 ++++++++++++++++++ 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 @platform/codebase/@features/client-area/docs/client-quote-confirm.flow.md diff --git a/@platform/codebase/@features/client-area/docs/client-area-architecture.md b/@platform/codebase/@features/client-area/docs/client-area-architecture.md index e76acc1..55116e4 100644 --- a/@platform/codebase/@features/client-area/docs/client-area-architecture.md +++ b/@platform/codebase/@features/client-area/docs/client-area-architecture.md @@ -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 diff --git a/@platform/codebase/@features/client-area/docs/client-document-types.contract.md b/@platform/codebase/@features/client-area/docs/client-document-types.contract.md index 1ad0816..9c2e737 100644 --- a/@platform/codebase/@features/client-area/docs/client-document-types.contract.md +++ b/@platform/codebase/@features/client-area/docs/client-document-types.contract.md @@ -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)`. diff --git a/@platform/codebase/@features/client-area/docs/client-quote-confirm.flow.md b/@platform/codebase/@features/client-area/docs/client-quote-confirm.flow.md new file mode 100644 index 0000000..f54bcc7 --- /dev/null +++ b/@platform/codebase/@features/client-area/docs/client-quote-confirm.flow.md @@ -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: '' } +``` + +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