Customer Portal (CP) - Architecture V1 (with V0 envelope)¶
Module:
customer-portal- prefixCP- plane: Application (com.altarys.papillon.pcs.customer)
PRD:docs/prd/customer-portal-prd.md.
Authored by: ARCHITECT session, 2026-05-21.
Document scope: V1 target with explicit V0 sub-version deltas. Cross-module decisions (D1–D23) and conventions indocs/architecture/PROGRESS.mdare reused verbatim , not re-debated. Legal status : magic-link as opposable consent =INCERTAIN - avocat requis⇒ every CP story carries[LEGAL-REVIEW]. Renouvellement à l'identique uniquement. Status: approved by Emmanuel BLONVIA on 05/23/2026
0. V0 Envelope & sub-version deltas¶
| Sub-version | Slice | CP scope | New external deps |
|---|---|---|---|
v0.0.0 |
CP-S0 | Dev environment + test infra (Postgres + Keycloak from TENANT S0; no new container). | none |
v0.0.0 |
CP-001 | Landing + contract list (read-only). No payment. Signed-link auth only (no Date Of Birth (DOB) challenge yet , AUTH delivers that at V1). All-or-none selection. No link TTL enforcement. Publishes customer.session_opened Spring event (OQ-X7). |
none |
v0.0.1 |
CP-002 | Live payment via PAY. Webhook + reconciliation. Server-side form draft persisted across link re-use. | PAY sandbox |
v0.0.2 |
CP-003 | Phone-last-4 capture at payment (not full ID verification). Per-contract toggle UI. Status polling + manual retry. | none |
v0.0.3 |
CP-004 | Lazy-load of payment-detail bundle (route-split optimisation). | none |
v0.1.0 |
CP-005 | Hardening: 72h link TTL, attempt counter, rate-limiting. | none |
V1 |
CP-006+ | Full PRD: AUTH DOB/RCCM challenge integration, anti-replay full, fine-grained selection. | ----- |
Locked from v0.0.0: mobile-first 360 px, bundle critical-path ≤ 200 KB gzipped (current floor 74.55 KB per D23), FR-only UI, no localStorage, no service worker, no IndexedDB. Server holds every survivable state.
1. System Context¶
flowchart LR
customer((Assuré<br/>Android Slow-3G))
cp[CP module<br/>Application plane]
auth[AUTH module<br/>SignedLinkService<br/>+ session opener]
pay[PAY module]
tenant[TENANT module<br/>TenantConfigCache]
ing[ING module<br/>contracts repo]
notif[NOTIF module]
audit[AUDIT module<br/>via outbox]
op[OP module<br/>file-detail timeline]
psp[(Merchant PSP<br/>OM / Wave / MoMo)]
minio[(MinIO<br/>receipts)]
customer -- HTTPS, HttpOnly cookie --> cp
cp -- Spring named API --> auth
cp -- REST in-process --> pay
cp -- bean --> tenant
cp -- read-only repo --> ing
pay -- 303 redirect URL --> customer
customer -- return URL --> cp
psp -- webhook --> pay
pay -- presigned URL --> customer
minio -- streams --> customer
cp -- ApplicationEvent customer.session_opened --> notif
pay -- PaymentConfirmed --> notif
cp -- outbox audit events --> audit
cp -. read-only events .-> op
CP is a stateless Spring Modulith module + a route-split React 19.2 SPA served from frontend/customer/.
It owns the customer-facing screens (CP-01…CP-13) and the server-side state required to make every screen resumable inside the 72h signed-link window.
CP does not own: link issuance/verification (AUTH), payment integration & receipts (PAY), notification dispatch (NOTIF), contract data (ING), tenant config (TENANT), audit storage (AUDIT).
1.1 Cross-module integration points (consumed)¶
| Producer | What CP consumes |
|---|---|
| AUTH | SignedLinkService.verifyAndOpen(token) returns (tenant_id, agency_id, customer_file_id, signed_link_id, country_code, correlation_id) or a typed revoke/expiry/lockout state. customerSessionAuthFilter consumes the HttpOnly cookie. |
| TENANT | TenantConfigCache.get(tenantId) for broker identity surface (logo, names, agrément, phones, primary colour, mentions-légales version). Spring event TenantSettingsChanged invalidates render cache. |
| ING | ContractRepository.findEligibleForFile(tenantId, customerFileId) returns the eligible set (status PENDING_RENEWAL, prime > 0, not flagged). |
| PAY | PaymentClient.initiate(...), getStatus(ref), getReceiptUrls(ref). Inbound Spring events: PaymentConfirmed, PaymentFailed, PaymentExpiredPending (mirror local projection). |
1.2 Cross-module emissions (produced)¶
| Event / Surface | Consumer | Notes |
|---|---|---|
Spring app event customer.session_opened {tenant_id, customer_id, signed_link_id, opened_at, correlation_id} |
NOTIF (cancel WA cascade) | OQ-X7. Emitted exactly once per signed link on first authenticated contract-list render. |
| 17 audit events (PRD §B.3) via outbox | AUDIT | correlation_id propagated from signed_link (D16). |
| CP audit events readable as timeline | OP | OP queries AUDIT, no direct coupling. |
1.3 correlation_id propagation (D16 leg confirmed by CP)¶
OP mints correlation_id at OP_CAMPAIGN_VALIDATED; AUTH stores it on the signed_links row; NOTIF carries it through cascade. CP reads correlation_id from the AUTH session-open response, stamps it on customer_sessions.correlation_id, and propagates it on every audit event + on PAY initiation requests (so PAY's events carry the same id). OQ-X3 (CP leg) is RESOLVED.
2. Data Model¶
All CP tables live in the tenant DB (application-plane), routed via tenantDataSource per D1. Identical schema across SHARED and BYO profiles.
erDiagram
customer_sessions ||--o{ payment_attempts : "has 0..* (1 in-flight at a time)"
customer_sessions ||--o{ cp_readonly_sessions : "post-ALL_PAID re-download"
signed_links ||--|| customer_sessions : "AUTH owns; CP FK"
payment_attempts }o--|| contracts : "frozen contract_ids[]"
customer_sessions }o--|| customers : "via customer_file"
customer_sessions {
uuid customer_session_id PK
uuid tenant_id FK
uuid agency_id FK
uuid customer_file_id FK
uuid signed_link_id FK
text state "ENUM CP state machine"
uuid[] selected_contract_ids
text consent_content_version
timestamptz consent_ticked_at
uuid inflight_transaction_id FK "partial UNIQUE per CP-NFR-15"
text session_token_hash "SHA-256 of cookie value"
text correlation_id "from signed_link"
text country_code "denormalised from tenant"
timestamptz created_at
timestamptz last_activity_at
timestamptz expires_at "= signed_link.expires_at"
}
payment_attempts {
uuid payment_attempt_id PK
uuid customer_session_id FK
uuid tenant_id FK
uuid agency_id FK
uuid customer_file_id FK
text transaction_reference UK
uuid idempotency_key UK
text provider
uuid[] contract_ids
bigint amount_total_minor "XOF integer minor units"
text currency "default 'XOF'"
text status "ENUM CREATED..EXPIRED_PENDING"
text provider_error_code
text mapped_reason
int attempt_index
timestamptz created_at
timestamptz confirmed_at
}
cp_readonly_sessions {
uuid id PK
uuid signed_link_id FK
uuid tenant_id FK
uuid customer_file_id FK
text ip_hash
text ua_hash
text cookie_token_hash
timestamptz created_at
timestamptz expires_at "+5 min sliding, bounded by signed_link.expires_at"
}
mentions_legales_versions {
uuid tenant_id FK
text content_version PK_PART
text content_html
timestamptz published_at
}
2.1 Indexing strategy¶
| Table | Index | Rationale |
|---|---|---|
customer_sessions |
(tenant_id, signed_link_id) UNIQUE |
One CP session per signed link. |
customer_sessions |
(tenant_id, customer_file_id) |
OP timeline join. |
customer_sessions |
partial UNIQUE (customer_session_id) WHERE inflight_transaction_id IS NOT NULL |
CP-NFR-15: ≤1 non-terminal tx at a time. |
customer_sessions |
(session_token_hash) UNIQUE |
cookie verification on every XHR. |
payment_attempts |
(tenant_id, customer_session_id, status) |
poll resolution. |
payment_attempts |
(transaction_reference) UNIQUE |
return-URL lookup. |
payment_attempts |
(idempotency_key) UNIQUE |
dedup across initiation retries. |
cp_readonly_sessions |
(cookie_token_hash) UNIQUE |
cookie verification. |
cp_readonly_sessions |
(expires_at) BRIN |
sweep job. |
mentions_legales_versions |
(tenant_id, content_version) PK |
render lookup. |
2.2 Tenant isolation invariants (Rule #1)¶
- Every CP repository call accepts
TenantContext tenantIdas the first parameter; every JdbcClient query carriestenant_id = :tenantIdin theWHEREclause. SonarQube custom rule + integration test rejects any CP query lacking the predicate (see §13). customer_session_idderived solely from the cookie; never from query string or header. The cookie-bound row carries its owntenant_id. CP NEVER trusts a URL-derived tenant.- Cross-tenant access on a CP endpoint is structurally impossible because the cookie-bound session is the only seed.
3. API Design¶
Namespace claimed in PROGRESS.md: /api/portail/{token}/... for the unauthenticated entry, /api/portail/session/... for cookie-bound XHRs. All payloads are JSON, gzip negotiated. Responses follow api-guidelines.md (cursor pagination D6, RFC 7807 errors).
3.0 API Versioning Strategy¶
API versioning uses header-based versioning via Spring Boot 4's native ApiVersionConfigurer.
- Header:
X-API-Version: <integer>(e.g.X-API-Version: 1). Required on all XHR calls. - Default when absent: Latest available version (in V0/V1 this is version 1).
- Deprecation policy: Versions entering deprecation receive:
Deprecation: trueresponse header.Sunset: <RFC 7231 date>response header marking the end-of-life date (90-day window from deprecation).- Requests to fully removed versions (past Sunset) receive 410 Gone +
Sunset-Infoheader. - Configuration: Spring Boot 4
ApiVersionConfigurerbean enables header-based versioning globally inCustomerPortalConfiguration. - Exception — Webhook ingress endpoints: URLs registered with external providers (e.g. PSP callback URLs, FNE registration events) retain URI versioning (e.g.
/webhooks/psp/{provider}/v1) because the URL is part of the third-party contract. This is a documented carve-out, not a drift. - React client practice: All XHR calls must route through a single fetch wrapper (
apiClient.ts) that injects theX-API-Versionheader. Rawfetch('/api/...')without the wrapper is a lint-rule violation. - Rationale: Header versioning preserves REST principles (resource identity unchanged across versions) while remaining operationally clean for internal APIs. Spring Boot 4 native support eliminates custom
RequestConditionoverhead.
3.1 Endpoint surface (V0 ⇒ V1)¶
| Method | Path | Auth | Available | Purpose |
|---|---|---|---|---|
| GET | /api/portail/{token} |
signed-link | v0.0.0 | Entry. AUTH verifies token, CP opens session row, sets HttpOnly cookie, 303 to /portail/contrats. |
| GET | /api/portail/session/contrats |
cookie | v0.0.0 | Eligible contract list (broker block + per-contract panel + selection + inflight-tx hint). |
| PUT | /api/portail/session/selection |
cookie | v0.0.0 | Replace selection set. Server strips ineligible ids, returns canonical set. |
| GET | /api/portail/session/recap |
cookie | v0.0.1 | Pre-payment summary view (re-checks eligibility intersection). |
| POST | /api/portail/session/consent |
cookie | v0.0.1 | Capture consent tick; binds content_version + timestamp to session. |
| POST | /api/portail/session/payments |
cookie + Idempotency-Key header |
v0.0.1 | Calls PAY; 303 redirects to PSP. Idempotent via session's inflight_transaction_id. |
| GET | /api/portail/session/return |
cookie | v0.0.1 | PSP return URL. Reads tx ref, routes to CP-07/08/09 screen state. |
| GET | /api/portail/session/payments/{ref}/status |
cookie | v0.0.2 | Polling endpoint. Returns {status, mapped_reason?, polled_at}. Strong ETag for client cache. |
| GET | /api/portail/session/payments/{ref}/recus |
cookie OR readonly | v0.0.1 | Per-contract receipt URLs from PAY (≤5 min signed MinIO). Emits CP_RECU_DOWNLOADED. |
| GET | /api/portail/session/mentions-legales |
cookie OR readonly | v0.0.0 | Static legal block (rendered with tenant override). Emits customer_mentions_legales_viewed. |
| POST | /api/portail/session/support/click |
cookie | v0.0.0 | Audit-only customer_support_contact_clicked (channel=tel\|wa). |
| GET | /api/portail/{token}/recus |
readonly-cookie | v0.0.1 | CP-13 entry after AUTH revoked link with reason=ALL_PAID. Issues cp_readonly_sessions cookie. |
Note on version column: The "Available" column refers to the sales-milestone version (v0.0.0, v0.0.1, etc.), not the API version. All endpoints ship with X-API-Version: 1 and remain at version 1 through V1; API version increments only when response schema changes in a non-backward-compatible way.
3.2 Compact response shapes (bandwidth budget)¶
Contract list response (typical 1–6 contracts) targets ≤ 6 KB gzipped. Example:
{
"v": 1,
"tnt": {
"name": "Cabinet ABC",
"agr": "20-CI-0142",
"tel": "+225 27 22 41 56 30",
"wa": "+225 07 07 12 34 56",
"logo": "https://cdn.../t/abc.png",
"color": "#0F4C81"
},
"ses": {
"id": "9a...",
"sel": ["c1", "c3"],
"tx": null,
"exp": "2026-05-24T14:00:00Z"
},
"ctr": [
{ "id": "c1", "br": "AUTO", "no": "12345", "per": "2026-06-01/2027-05-31", "prm": 185000, "g": "...", "x": "..." },
{ "id": "c2", "br": "HAB", "no": "67890", "per": "...", "prm": 95000, "g": "...", "x": null }
]
}
- All keys short (
tnt,ctr,prm); server-side. Client maps to a typed model. Saves ~25% over verbose JSON. - Money carried as integer minor units (
prm: 185000); client formats as185 000 XOF. - Tenant logo URL points to a CDN-cached image (TENANT-owned).
ses.explets the client surface link-expiry copy without an extra call.
3.2.1 Short-key legend¶
| Key | Stands for | Type / Format | Notes |
|---|---|---|---|
v |
schema version | integer | Payload envelope version. Clients warn-then-reload on major bump. |
tnt |
tenant | object | Broker identity surface (name, agrément, contact, logo, color). Rendered per CP-NFR-13. |
agr |
agrément | string | Broker license number (CIMA registry). |
tel |
téléphone | string, E.164 | Broker phone number (click-to-call). |
wa |
string, E.164 | Broker WhatsApp number (click-to-WhatsApp). | |
logo |
logo URL | HTTPS string | Tenant-owned CDN image (max 60 KB WebP). |
color |
brand color | string, hex #RRGGBB | Primary brand colour for UI theming. |
ses |
session | object | Customer session metadata. |
id |
session id | UUID string | Opaque session identifier (not the cookie value). |
sel |
selection | array of strings | Array of selected contract IDs. |
tx |
transaction | UUID string or null | In-flight payment transaction id (null if none). |
exp |
expiry | ISO 8601 timestamp (UTC) | Session expiry (= signed_link TTL). Lets client warn user. |
ctr |
contracts | array of objects | Array of eligible contract summaries for renewal. |
id |
contract id | string | Contract identifier (backend opaque; used in selection). |
br |
branche | string enum (AUTO, HAB, VIE, …) | Insurance product line. |
no |
numéro | string | Contract/policy number (human-readable). |
per |
période | string, ISO interval YYYY-MM-DD/YYYY-MM-DD |
Coverage period start/end. |
prm |
prime | integer, XOF minor units | Premium amount. Display as prm ÷ 100 formatted with space thousands separator (e.g. 185000 → 1 850 XOF). Never use float. |
g |
garanties | string (summary) or null | Short text summary of coverage (e.g. "Incendie, Vol"). null if details unavailable. |
x |
extras | string (notes) or null | Optional flags or extra info (e.g. "Attestation en attente"). null if none. |
Typing note: All keys are compact on the wire; client-side TypeScript interfaces should name them verbosely for clarity. The mapping happens in apiClient.ts at deserialization time.
3.3 Versioning & evolution¶
- Response shape carries a
vinteger (the payload schema version, independent of the HTTP headerX-API-Version); clients warn-then-reload on majorvbump. - API endpoint versioning is header-based (
X-API-Version); response shape versioning is payload-embedded (v). These are distinct concerns. - New fields added to the payload are nullable; removals are gated through a one-version deprecation cycle in per-module API standards.
- The endpoint path itself never contains a version segment (no
/api/v1/...in CP-internal endpoints). Only webhook ingress endpoints to external providers retain URI versioning per §3.0 exception.
3.4 Cache headers (CP-NFR-12)¶
- HTML responses:
Cache-Control: no-store. - JSON responses:
Cache-Control: private, max-age=0, must-revalidate+ strongETag. - Static React bundle:
Cache-Control: public, max-age=31536000, immutable(hashed filenames). - Logo/CDN:
Cache-Control: public, max-age=86400.
4. Multi-Tenant Strategy¶
4.1 Tenant resolution¶
| Entry point | Resolver |
|---|---|
GET /api/portail/{token} |
AUTH.SignedLinkService.verifyAndOpen(token) returns the tenant id; CP attaches TenantContext and creates the cookie-bound session. |
* /api/portail/session/* |
CustomerSessionAuthFilter reads HttpOnly cookie, looks up customer_sessions.session_token_hash, attaches TenantContext(tenant_id) from the row. |
GET /api/portail/{token}/recus (CP-13) |
Token resolved through AUTH; on revoked_reason=ALL_PAID CP creates cp_readonly_sessions and a separate cookie. |
CP never trusts a tenant_id derived from URL or header (D3 + Rule #1).
4.2 Agency sub-scoping¶
CP renders per-agency identity (phone, name) from the customer_files.agency_id snapshot at file-issuance time (R-CP-037).
A disabled agency does not block an in-flight customer renewal.
4.3 V1 DB profiles (SHARED + BYO) - uniform code path¶
Per D1/D9, both profiles use identical schema. TenantDataSourceRouter (TENANT-owned bean) returns the right DataSource based on TenantContext.
CP's JdbcClient is wired against the dynamic routing datasource. No profile-aware code in CP.
Flyway migrations under db/migration/tenant/CP/ are applied on startup to the shared DB and during BYO-provisioning to each BYO connection.
4.4 V2-non-breaking checks¶
customer_sessions.country_codedenormalized - picks up SN/BJ values without schema change.- No global UNIQUE on tenant-scoped fields without
tenant_idprefix. - No CP behaviour resolved from URL/host - adding tenant subdomains in V2 needs no migration.
4.5 Country-profile reads¶
CP reads country_code from TenantConfigCache.get(tenantId).countryCode. V1 only branch: receipt-PDF locale = fr-FR.
V2+ adds PSP-provider lookup (already wired in PAY) and per-country legal-defaults source - CP keeps the resolver indirection so adding SN/BJ is a TENANT seed change.
5. Connectivity & Resumability¶
Customer side is NOT offline-first. No service worker, no IndexedDB, no client mutation queue (CLAUDE.md Rule #11, locked).
5.1 Signed-link scheme (AUTH-owned, CP consumer)¶
- Token: 32-byte SecureRandom Base64url (AUTH ADR-02).
- TTL: 72 h (V1; not enforced until v0.1.0 CP-005).
- Anti-replay: attempt counter + lockout cycles (AUTH-owned). CP only sees verifier results.
- Re-issuance:
REISSUEDis a CP-distinguishing revoke reason (R-CP-028).
5.2 HttpOnly session cookie (CP-side)¶
- Name:
cp_session. - Attributes:
HttpOnly; Secure; SameSite=Lax; Path=/api/portail/session; Max-Age=259200(72 h ceiling). - Value: opaque 32-byte token; SHA-256 hash stored as
customer_sessions.session_token_hash. - Issued only after
AUTH.verifyAndOpensucceeds. Cleared on revoke / expiry / TENANT_SUSPENDED. - Independent of the read-only cookie (
cp_readonly, path/api/portail/{token}/recus, 5 min sliding).
5.3 Server-side state, not client¶
- Selection lives on
customer_sessions.selected_contract_ids(R-CP-006). - Consent lives on
customer_sessions.{consent_content_version, consent_ticked_at}(R-CP-011). - In-flight transaction lives on
customer_sessions.inflight_transaction_id+payment_attemptsrow (CP-NFR-15). - A reload at any point reads exactly this set and routes the screen - no extra reconciliation.
5.4 Payment idempotency¶
- Client generates
Idempotency-Key(UUIDv4) on the user's "Payer" click; CP rejects a second initiation whileinflight_transaction_id IS NOT NULL(returns the existing tx). - CP forwards key to PAY; PAY deduplicates upstream.
5.5 Status reconciliation (webhook + polling, no SSE in V1)¶
- PSP webhook → PAY →
PaymentConfirmed/PaymentFailed/PaymentExpiredPending→ CP listener updates the localpayment_attemptsprojection. - Client (TanStack Query,
refetchInterval): 5 s × first 12 polls then 30 s indefinite while status=PENDING.refetchIntervalInBackground=false(tab-hidden pauses polling per R-CP-020). - Reload re-enters CP-08 if status is still
PENDING(R-CP-021).
6. Sequence Diagrams¶
6.1 Happy path - landing → confirmed payment¶
sequenceDiagram
actor C as Customer
participant B as Browser
participant CP as CP API
participant A as AUTH
participant T as TENANT
participant I as ING
participant P as PAY
participant PSP as PSP
participant N as NOTIF
C->>B: click WA/SMS link
B->>CP: GET /api/portail/{token}
CP->>A: verifyAndOpen(token)
A-->>CP: {tenant, agency, file, corr_id}
CP->>CP: insert customer_sessions, mint cookie
CP-->>B: 303 + Set-Cookie cp_session
CP-->>N: publish customer.session_opened (event)
B->>CP: GET /api/portail/session/contrats (cookie)
CP->>T: TenantConfigCache.get
CP->>I: findEligibleForFile(tenant, file)
CP-->>B: contract list JSON (~6 KB)
C->>B: select 2 contracts
B->>CP: PUT /selection
CP-->>B: canonical set
C->>B: tap Payer
B->>CP: GET /recap
C->>B: tick consent
B->>CP: POST /consent
C->>B: tap "Payer 280 000 XOF"
B->>CP: POST /payments (Idempotency-Key)
CP->>P: initiate
P-->>CP: {ref, redirect_url}
CP-->>B: 303 to PSP
B->>PSP: PSP hosted page
C->>PSP: enter OM PIN
PSP-->>B: 303 to return_url
B->>CP: GET /return?ref=...
CP->>P: getStatus(ref)
P-->>CP: CONFIRMED
CP-->>B: render CP-07 receipt screen
C->>B: tap télécharger reçu
B->>CP: GET /payments/{ref}/recus
CP->>P: getReceiptUrls(ref)
P-->>CP: signed MinIO URLs
CP-->>B: list of presigned URLs
B->>B: open URL (direct from MinIO)
P-->>N: PaymentConfirmed → SMS+WA recap
6.2 Network drop during selection¶
sequenceDiagram
actor C as Customer
participant B as Browser
participant CP as CP API
C->>B: toggle contract c3
B->>B: debounce 500 ms
B->>CP: PUT /selection [c1,c3]
CP--xB: TCP timeout (3 s server-side ceiling)
B->>B: "saved" dot stays amber
B->>B: banner after 5 s: "Connexion instable. Vos choix sont enregistrés."
Note over B: Local UI shows c3 selected (optimistic), retries with backoff.
B->>CP: PUT /selection [c1,c3] (retry @10 s)
CP-->>B: 200, canonical [c1,c3]
B->>B: clear banner, dot green
6.3 Customer returns mid-flow days later (link still alive, tx PENDING)¶
sequenceDiagram
actor C as Customer
participant B as Browser
participant CP as CP API
participant A as AUTH
participant P as PAY
C->>B: re-click same link
B->>CP: GET /api/portail/{token}
CP->>A: verifyAndOpen
A-->>CP: AUTHENTICATED (TTL ok)
CP->>CP: lookup session by signed_link_id; cookie re-issued
CP-->>B: 303 → /portail/session/return
B->>CP: GET /api/portail/session/return (no ref param)
CP->>P: getStatus(session.inflight_tx)
P-->>CP: PENDING
CP-->>B: CP-08 screen
B->>CP: poll /payments/{ref}/status (TanStack Query)
P--)CP: webhook arrives → CONFIRMED
B->>CP: next poll → CONFIRMED
CP-->>B: route to CP-07
6.4 Payment status unknown - pending then expired¶
sequenceDiagram
participant B as Browser
participant CP as CP API
participant P as PAY
B->>CP: POST /payments
CP->>P: initiate
P-->>CP: {ref, redirect_url}
CP-->>B: 303 PSP
Note over B: customer never returns (closes tab)
B->>CP: hours later, re-click link
CP->>P: getStatus(ref)
P-->>CP: PENDING
CP-->>B: CP-08 + polling
P->>P: reconciliation timeout (~30 min after init)
P->>CP: PaymentExpiredPending
CP->>CP: update payment_attempts.status = EXPIRED_PENDING
B->>CP: next poll
CP-->>B: CP-09 "le paiement n'a pas pu être confirmé. Aucun montant n'a été prélevé."
6.5 Link replaced mid-flow¶
sequenceDiagram
participant B as Browser (old link)
participant CP as CP API
participant A as AUTH
B->>CP: PUT /selection (cookie of old session)
CP->>A: validate (filter)
A-->>CP: REVOKED reason=REISSUED
CP-->>B: 401 + body {revoke_reason: REISSUED}
B->>B: hard reload → /portail/{old-token}
CP->>A: verifyAndOpen
A-->>CP: REVOKED REISSUED
CP-->>B: CP-10 "Un nouveau lien vous a été envoyé"
6.6 CP-13 receipt re-download after ALL_PAID auto-revoke¶
sequenceDiagram
actor C as Customer (3 days later)
participant B as Browser
participant CP as CP API
participant A as AUTH
participant P as PAY
C->>B: re-click original link
B->>CP: GET /api/portail/{token}
CP->>A: verifyAndOpen
A-->>CP: REVOKED reason=ALL_PAID
CP->>CP: create cp_readonly_sessions row (5 min)
CP-->>B: 303 to /api/portail/{token}/recus + Set-Cookie cp_readonly
B->>CP: GET /api/portail/{token}/recus (cookie)
CP->>P: list confirmed tx for file
CP-->>B: per-contract download buttons
C->>B: tap download
B->>CP: GET /payments/{ref}/recus
CP->>P: getReceiptUrls
P-->>CP: presigned MinIO URLs
CP-->>B: URLs
7. Security¶
| Layer | Measure |
|---|---|
| Transport | TLS 1.2+, HSTS on portail.papillon.ci (and tenant aliases V2+). |
| Auth (entry) | Signed link - AUTH-owned. CP NEVER decodes the token; calls SignedLinkService only. |
| Auth (XHR) | cp_session HttpOnly cookie ⇒ SHA-256 hash lookup on customer_sessions.session_token_hash. Cookie value never logged. |
| Auth (CP-13) | cp_readonly HttpOnly cookie ⇒ SHA-256 hash lookup on cp_readonly_sessions.cookie_token_hash. Cannot mutate. |
| CSRF | All mutating endpoints require Origin/Referer match the portal domain. SameSite=Lax cookie + POST/PUT-only mutations + JSON-only body (no form posts) blocks classic CSRF. |
| Anti-replay | inflight_transaction_id partial UNIQUE; return-URL transaction_reference must equal session's in-flight ref (R-CP-016). |
| Idempotency | Idempotency-Key header required on POST /payments; rejected if violates uniqueness or session already has non-terminal tx. |
| Tenant isolation | TenantContext seeded only from cookie-bound session row. Repository layer rejects queries without tenant_id. Integration test enforces (§13). |
| Audit logging | Every event in PRD §B.3 via spring-modulith outbox (D4 + D7). Failure to audit does not roll back business action. correlation_id propagated (D16). |
| Cookie discipline | HttpOnly + Secure + SameSite=Lax. No PII in cookie value. Token re-roll on every AUTH.verifyAndOpen (e.g. customer re-enters via the same link from a new device). |
| Secret mgmt | Cookie HMAC key (if used in v0.1.0+) from env / KMS, rotated via overlap-key strategy. PSP keys stay in PAY. |
| PII minimisation | Phone / email / DOB never appear in CP responses. customer_sessions.country_code denormalised but not PII. |
| Browser cache | All HTML/JSON no-store / private. Static bundle aggressively cached by content hash. |
| Rate limit | v0.1.0 (CP-005): per-IP + per-link counters on POST /payments and POST /consent at reverse proxy (Coolify nginx). v0.0.x defers to PSP rate limits. |
| Headers | Content-Security-Policy: default-src 'self'; img-src 'self' https:; connect-src 'self' https://api.papillon.ci; strict no-inline. |
7.1 RBAC¶
Customer side has no roles - single anonymous principal scoped by customer_sessions. Operator/admin do not touch CP endpoints (separate paths).
7.2 Audit event reference¶
| Event | Trigger | Side |
|---|---|---|
customer_session_opened |
session row inserted | CUSTOMER |
customer_contract_list_viewed |
GET /contrats | CUSTOMER |
customer_contract_details_missing |
row with null garanties/exclusions surfaced | BACKEND |
customer_selection_changed |
PUT /selection delta | CUSTOMER |
customer_summary_viewed |
GET /recap | CUSTOMER |
customer_consent_captured |
POST /consent | CUSTOMER |
customer_mentions_legales_viewed |
GET /mentions-legales | CUSTOMER |
customer_payment_initiated |
POST /payments → 303 | CUSTOMER |
customer_payment_initiation_failed |
PAY 5xx or timeout | BACKEND |
customer_payment_return_mismatch |
return-URL ref ≠ session ref | BACKEND |
customer_payment_status_polled |
each poll (sampled to ≤ 1/5 s + final transition) | BACKEND |
customer_payment_confirmed |
PaymentConfirmed applied | CUSTOMER |
customer_payment_failed |
PaymentFailed applied | CUSTOMER |
customer_payment_expired_pending |
PaymentExpiredPending applied | BACKEND |
CP_RECU_DOWNLOADED |
GET /recus + click | CUSTOMER |
customer_offline_banner_shown |
client posts a beacon (best-effort, no retry) | BACKEND |
customer_link_replaced_landing |
CP-10 render | CUSTOMER |
customer_already_paid_landing |
CP-13 render | CUSTOMER |
customer_support_contact_clicked |
POST /support/click | CUSTOMER |
8. Performance & Bandwidth Budget¶
8.1 Gzip budget tracker (D23 - baseline 74.55 KB)¶
| Critical-path surface | Bundle floor | Notes |
|---|---|---|
index.html + critical CSS |
~5 KB | inlined first-paint CSS for WelcomePage + CustomerShell. |
| React 19.2 + React Router v7 | 74.55 KB (D23) | locked baseline. |
| TanStack Query (v5, ESM, tree-shaken) | ~13 KB | NEW dependency, gates polling on CP-08. Justified against 200 KB ceiling. |
| CP page chunk: WelcomePage + ContractList | ~14 KB | route-split, lazy. |
| CP page chunk: Summary + Consent | ~8 KB | route-split, lazy (loaded on "Payer" intent). |
| CP page chunk: Payment status (CP-08/09) | ~6 KB | loaded after redirect-return. |
| CP page chunk: Receipt (CP-07/13) | ~5 KB | loaded on confirmation. |
| Static legal page (CP-12) | ~2 KB | server-rendered HTML, no JS. |
| Total critical path (landing) | ~107 KB | well under 200 KB ceiling. Headroom for icons (SVG sprite ≤ 4 KB) and i18n shim. |
Bundle is monitored on every CI build via vite-bundle-visualizer + size-budget script that fails the build above 180 KB gzipped on the landing path.
8.2 Render & query optimisation¶
- Contract list query is a single JDBC fetch, hydrated with TENANT/agency-snapshot fields from
TenantConfigCache(in-memory, refresh onTenantSettingsChanged). - Polling status endpoint is a single indexed read on
payment_attempts (transaction_reference)+ ETag - every poll past the first returns 304 if status unchanged. - All
Content-Encoding: gzip(br for browsers that accept it).
8.3 Image strategy¶
- Tenant logo: ≤ 60 KB WebP, served by CDN (Coolify static), max-width 200 px. TENANT enforces upload cap.
- No font downloads on critical path - system font stack only.
- SVG icons inline, single sprite, no icon font.
8.4 Slow-3G smoke test (CP-NFR-09)¶
- Chrome DevTools
Slow 3Gprofile (400 Kbps down / 400 Kbps up / 2 s RTT). - TTI budget on landing ≤ 6 s; FCP ≤ 3 s.
- Verified per story before declaring done (CLAUDE.md Rule #12).
9. Migration Strategy¶
Per D9: Flyway directory db/migration/tenant/CP/. Identical schema across SHARED + BYO profiles. Migrations applied on startup (shared DB) and during BYO-provisioning (per-tenant connection).
9.1 v0.0.0 baseline migration (V0_0_0_001__cp_baseline.sql)¶
Creates customer_sessions, payment_attempts, cp_readonly_sessions, mentions_legales_versions with the full V1 column set (including consent_*, inflight_transaction_id, correlation_id, country_code, expires_at). Schema frozen across V0.0.0 → V1 to avoid ALTER TABLE between sub-versions (mirrors AUDIT-ADR-03). Unused columns are nullable with sensible defaults.
9.2 Reversibility¶
- Each migration ships with a documented down step (
db/migration/tenant/CP/down/). - Down is operational (DBA-applied), not auto-run, to avoid accidental destructive rollbacks of production data.
customer_sessionsandpayment_attemptsrows are append-mostly; rolling back code without rolling back schema is safe (newer columns ignored).
9.3 Data backfill posture¶
- No backfill needed for v0.0.0 (greenfield).
- v0.0.1 introduces in-flight payment columns - pre-existing v0.0.0 rows initialised with
NULL. - v0.0.2 introduces
phone_last4snapshot - populated from PAY at attempt creation time only; historical rows stayNULL.
10. Integration Points¶
| Integration | Direction | Protocol | Ownership | Notes |
|---|---|---|---|---|
| PAY (in-process) | CP → PAY | Spring named iface | PAY | PaymentClient - initiate, status, receipt URLs. Forwards Idempotency-Key. |
| AUTH (in-process) | CP → AUTH | Spring named iface | AUTH | SignedLinkService, CustomerSessionService. |
| TENANT (in-process) | CP → TENANT | Bean call | TENANT | TenantConfigCache. Invalidates on event. |
| ING (in-process) | CP → ING | Spring repo | ING | Read-only ContractRepository. |
| NOTIF (event) | CP → NOTIF | ApplicationEvent |
NOTIF | customer.session_opened cancels WA cascade (D17, OQ-X7). |
| AUDIT (event) | CP → AUDIT | Outbox (D4) | AUDIT | 17 event types (PRD §B.3). |
| OP (event) | CP → OP | Reads AUDIT | OP | No direct API surface. |
| PSP (external) | Customer ↔ PSP | HTTPS hosted page | PAY | CP only does a 303 to PAY's redirect_url. |
| MinIO (external) | Browser ↔ MinIO | HTTPS presigned URL | PAY/storage | CP never proxies PDF body; ≤ 5 min URL TTL (CP-NFR-07). |
| CDN (logo) | Browser ↔ CDN | HTTPS | TENANT | Static tenant logo. |
| Country profile | CP read | TenantConfigCache |
TENANT | Resolves PSP provider list (V2+), currency (V1 = XOF only), legal-defaults source. |
| Platform→tenant e-invoicing (FNE) | n/a from CP | n/a | BILL | CP does not interact with FNE; PAY/BILL handle merchant→broker leg. Noted for completeness. |
11. Vertical Slice Decomposition¶
PO input - not for DEVELOPER. The DEVELOPER reads only the story file at
docs/stories/customer-portal/<STORY-ID>.md. This section exists for the PRODUCT_OWNER to decompose into self-contained stories.
Concurrency tiers per ARCHITECT spec: Tier 0 serial (must merge first), Tier 1+ parallelisable after their predecessor merges.
CP-S0 - Dev environment + test infra (Tier 0, v0.0.0) - S¶
- Reuse TENANT S0's
docker-compose.yml(Postgres + Keycloak + MinIO + Redis). No new container. - Add WireMock service for PAY sandbox stubbing (test-only profile).
- Add TestContainers wiring for CP integration tests (shared Postgres + tenant-DB switch).
- Smoke command:
./gradlew :backend:test+cd frontend/customer && pnpm test. - Files:
backend/build.gradle.kts(CP deps added to the single backend module),backend/src/test/resources/application-test.yml,frontend/customer/vitest.config.ts. - Dependencies: TENANT S0 merged.
- Deliverables: CI green on an empty CP module.
CP-001 - Landing + contract list, session cookie, no payment (Tier 1, v0.0.0) - M¶
- API:
GET /api/portail/{token},GET /api/portail/session/contrats,PUT /api/portail/session/selection,POST /api/portail/session/support/click. - Logic:
CustomerSessionAuthFilter, session-open via AUTHSignedLinkService.verifyAndOpen, eligible-set query, debounced selection, broker identity hydration via TENANT, audit emissions (customer_session_opened,customer_contract_list_viewed,customer_selection_changed,customer_offline_banner_shown,customer_support_contact_clicked,customer_contract_details_missing). - Persistence:
customer_sessions,mentions_legales_versionsmigrations. - Cross-module: publish
customer.session_openedSpring event (OQ-X7 closure). - UI:
WelcomePageadapter to real/portail/{token}route, newContractListPage(route/portail/session/contrats), debounced selection withuseDebouncedCallback, network-failure banner usinguseConnectivity. - Files:
backend/src/main/java/com/altarys/papillon/pcs/customer/{api,session,contracts,events}/*.java,db/migration/tenant/CP/V0_0_0_001__cp_baseline.sql,frontend/customer/src/pages/ContractListPage.tsx,frontend/customer/src/api/cpClient.ts. - AC count: 4 (
landing 303,list render with broker identity,selection debounce persist,network drop ⇒ banner + retry). - Dependencies: CP-S0; AUTH-S0 + AUTH signed-link service available; TENANT S0 + TenantConfigCache available; ING contracts query available.
CP-002 - Payment initiation + return + reçu, no polling yet (Tier 1, v0.0.1) - L (split candidate)¶
- API:
GET /api/portail/session/recap,POST /api/portail/session/consent,POST /api/portail/session/payments,GET /api/portail/session/return,GET /api/portail/session/payments/{ref}/recus. - Logic: idempotency-key gate, partial-UNIQUE on
inflight_transaction_id, 303 to PSP redirect, return-URL match (R-CP-016),PaymentConfirmedlistener updates projection, audit emissions forsummary_viewed,consent_captured,payment_initiated,payment_initiation_failed,payment_return_mismatch,payment_confirmed,CP_RECU_DOWNLOADED. - UI:
SummaryConsentPage,ReceiptPage,PaymentFailedPage, route-split chunk. - AC count: 7 → split candidate. Suggested sub-slices:
- CP-002a (
M) -/recap+/consent+ summary screen, consent capture audited, no PAY call yet (mock). - CP-002b (
M) -/paymentsinitiation, idempotency, redirect, PaymentConfirmed listener. - CP-002c (
S) - receipt screen +/payments/{ref}/recus+CP_RECU_DOWNLOADED. - Files: backend listener
PaymentEventListener, frontendPaymentRedirect.tsx,ReceiptPage.tsx. - Dependencies: CP-001; PAY initiate/status/receipt API live; NOTIF cp_payment_receipt template registered (NOTIF-US-11).
CP-003 - Pending state, polling, manual retry, phone-last-4 capture (Tier 1, v0.0.2) - M¶
- API:
GET /api/portail/session/payments/{ref}/status+ ETag. - Logic:
PaymentPending/PaymentFailedlisteners; mapped reason table; phone-last-4 column onpayment_attemptspopulated from PAY response. - UI:
PendingPaymentPagewith TanStack QueryrefetchInterval5 s × 12 then 30 s. Per-contract toggle UI (replaces all-or-none). - Audit:
customer_payment_status_polled(sampled),customer_payment_failed,customer_payment_expired_pending. - AC count: 4.
- Dependencies: CP-002 fully merged.
CP-004 - Lazy-load receipt + payment bundle, bundle budget enforcement (Tier 1, v0.0.3) - S¶
- Tighten route chunks; CI bundle-budget gate set at 180 KB gzipped for landing path.
- AC count: 2 (
landing path under 180 KB,receipt chunk only after redirect-return). - Dependencies: CP-003 merged.
CP-005 - Link TTL + attempt counter + rate limiting (Tier 1, v0.1.0) - M¶
- 72 h
expires_atenforced incustomer_sessions; AUTH cooperates. payment_attempts.attempt_indexrendered in audit (no platform cap per CP-TV-03).- Reverse-proxy rate limits configured in Coolify nginx.
- AC count: 3 (
expired link routes to EXPIRED screen,attempt_index increments,rate limit returns 429 + audit). - Dependencies: CP-002 merged (uses
payment_attempts); AUTH 72 h enforcement available.
CP-006 - V1 hardening: link-replaced screen, mismatch handling, déjà-réglé CP-13, mentions-légales page (Tier 1, V1) - M¶
- API:
GET /api/portail/{token}/recus(CP-13 entry),GET /api/portail/session/mentions-legales. cp_readonly_sessionsrow + cookie issuance.- CP-10 "lien remplacé" copy on
REVOKED reason=REISSUED. - AC count: 4.
- Dependencies: CP-005 merged.
12. Technical Risks¶
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| PAY webhook arrives before CP return-URL hit ⇒ race | Med | Low | CP's projection is updated by the event listener; return-URL just reads projection - race is harmless. |
| Two browser tabs of the same link initiate two payments | Med | High | Partial UNIQUE on inflight_transaction_id rejects second initiation. Idempotency-Key dedupe at PAY. |
| Customer closes tab during PSP redirect, link TTL elapses before PSP webhook | Low | Med | PAY emits EXPIRED_PENDING ⇒ CP-09 on next visit; no double-charge guaranteed by PSP idempotency. |
| Long-tail PENDING payments without webhook (PSP outage) | Low | Med | PAY polls PSP independently; CP defers to PAY's EXPIRED_PENDING terminal. |
| Customer on shared device clicks back-button to see other family's payment data | Med | High | Cache-Control: no-store on every CP HTML; cookie HttpOnly with hard TTL; CP-13 read-only is IP+UA bound. |
| Tenant logo URL down ⇒ broken broker block | Low | Low | Server fallback to text-only commercial name; client <img> onerror hides logo. |
| TanStack Query background refetch drains battery / data | Med | Low | refetchIntervalInBackground=false; cadence slows to 30 s after 60 s. |
| Receipt PDF URL expires before customer clicks | Med | Low | Client re-fetches list of URLs on every CP-07 mount; URLs are single-use within 5 min window. |
| Audit outbox grows under PG outage | Low | Med | spring-modulith-events-jdbc retains rows; D7 says audit failure must not roll back business action. |
| CSRF via cross-site link click | Low | High | SameSite=Lax + Origin/Referer match on mutations. |
| Cookie hash collision | Negligible | - | 32-byte random ⇒ 2^256 space; SHA-256 hash UNIQUE constraint catches the impossible collision. |
| Customer types token URL into an unsuitable browser (very old WebView) | Low | Low | Progressive enhancement: server-rendered fallback for CP-01 landing; JS-required surfaces show a noscript notice. |
13. Test Infrastructure¶
13.1 Stack¶
- Backend: JUnit 5, Spring Boot Test, TestContainers (PostgreSQL 16), spring-modulith-test, WireMock (PAY + AUTH + TENANT-event-stub when needed in cross-module tests). MockMvc for HTTP.
- Frontend: Vitest + Testing Library; Playwright for the Slow-3G smoke test.
- Contract tests: Pact between CP and PAY (CP is consumer; PAY publishes provider state).
13.2 Sample tenant-isolation test¶
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class CustomerSessionTenantIsolationTest {
@Autowired CustomerSessionRepository sessions;
@Autowired TestContext context;
@Test
void session_lookup_is_strictly_tenant_scoped() {
// Tenant A creates a session
var sA = context.asTenant("A").createCustomerSession();
// Tenant B tries to load it via the same id
context.asTenant("B").runIn(() -> {
var loaded = sessions.findById(sA.id());
assertThat(loaded).isEmpty();
});
// Direct cross-tenant attempt also blocked at repository
assertThatThrownBy(() ->
sessions.findByTenantIdAndId(UUID.fromString("...tenantB..."), sA.id())
).doesNotThrowAnyException(); // returns empty Optional, never the row
}
@Test
void selection_mutation_rejects_contract_ids_from_other_tenant() {
var sA = context.asTenant("A").createCustomerSession();
var ctrB = context.asTenant("B").createContract();
var canonical = context.asTenant("A")
.perform(put("/api/portail/session/selection")
.cookie("cp_session", sA.cookie())
.content("[\"" + ctrB.id() + "\"]"))
.andExpect(status().isOk())
.andReturn();
// ctrB silently stripped
assertThat(canonical.body()).doesNotContain(ctrB.id().toString());
}
}
13.3 Sample low-bandwidth resumability test¶
@Test
void resume_after_disconnect_returns_to_pending_screen() throws Exception {
var s = createSessionWithInflightPendingTx();
// simulate "page reload after network drop"
var first = mvc.perform(get("/api/portail/session/return")
.cookie("cp_session", s.cookie())).andReturn();
assertThat(first.getResponse().getStatus()).isEqualTo(200);
assertThat(first.getResponse().getContentAsString()).contains("\"screen\":\"PENDING\"");
// wait for webhook to flip to CONFIRMED
publishPaymentConfirmed(s.txRef());
var second = mvc.perform(get("/api/portail/session/payments/" + s.txRef() + "/status")
.cookie("cp_session", s.cookie())).andReturn();
assertThat(second.getResponse().getContentAsString()).contains("CONFIRMED");
}
13.4 Slow-3G E2E¶
Playwright config under frontend/customer/e2e/slow-3g.config.ts:
import { devices, defineConfig } from '@playwright/test';
export default defineConfig({
use: {
...devices['Pixel 5'],
contextOptions: {
networkProfile: { downloadThroughput: 50_000, uploadThroughput: 50_000, latency: 2000 }
}
},
expect: { timeout: 10_000 }
});
Asserts landing TTI ≤ 6 s, first contract row visible ≤ 4 s after navigation.
13.5 Services to mock¶
| External | V0.0.0 | V0.0.1+ |
|---|---|---|
| AUTH | Spring named iface stub | TestContainers Keycloak |
| TENANT | TenantConfigCache fake | real bean |
| PAY | WireMock + Pact | WireMock + Pact |
| MinIO | WireMock for presigned-URL gen | TestContainers MinIO |
| AUDIT | in-memory outbox | TestContainers PG |
14. Local Dev Environment¶
CP introduces no new container. Reuses TENANT S0's docker-compose.yml (Postgres, Keycloak, MinIO, Redis) + AUTH S0's realm config.
14.1 Services (existing)¶
| Service | Owner | Used by CP |
|---|---|---|
| postgres | TENANT S0 | customer_sessions, payment_attempts, projections |
| keycloak | TENANT S0 | not used by CP itself; required by AUTH for operator side |
| minio | TENANT S0 | not directly; PAY proxies via presigned URLs |
| redis | TENANT S0 | reserved for V2 fast-path (not used in V1 per decision §5.3) |
| wiremock | CP-S0 | PAY sandbox in tests only - profile=test |
14.2 Env vars (additions in .env.example)¶
# CP
CP_COOKIE_NAME=cp_session
CP_COOKIE_DOMAIN=portail.papillon.ci
CP_COOKIE_SECURE=true
CP_COOKIE_MAX_AGE_SECONDS=259200
CP_READONLY_COOKIE_NAME=cp_readonly
CP_READONLY_TTL_SECONDS=300
CP_BUNDLE_BUDGET_BYTES_GZIPPED=184320 # 180 KB landing path
CP_POLL_INITIAL_INTERVAL_MS=5000
CP_POLL_SLOW_INTERVAL_MS=30000
CP_POLL_SLOW_AFTER_MS=60000
PAY_CLIENT_BASE_URL=http://localhost:8080/internal/pay # in-process Spring named iface in real run
14.3 Smoke verification command¶
# from repo root
./gradlew :backend:bootRun -PspringProfiles=local &
cd frontend/customer && pnpm dev &
# ingest a test campaign and grab a signed link from logs, then:
curl -i "http://localhost:5173/api/portail/{token}"
# expect 303 Location: /portail/session/contrats and Set-Cookie: cp_session=...; HttpOnly; SameSite=Lax
ADRs (CP-specific)¶
- ADR-CP-01 - Customer XHR auth = HttpOnly
cp_sessioncookie (server-stored SHA-256 hash), not signed-link replay nor JWT. Why: paper-thin customer surface, no refresh dance, simple revoke, cookie never reveals tenant. - ADR-CP-02 - Selection + consent + in-flight tx live on a single
customer_sessionsrow in Postgres; no Redis fast-path in V0/V1. Why: write rate negligible, removes a coherence problem, one fewer failure mode. - ADR-CP-03 - Status polling implemented with TanStack Query (
refetchInterval,refetchIntervalInBackground=false). Why: battle-tested cadence + visibility handling, 13 KB gzip fits the 180 KB landing budget, foundation reusable for V1 file-detail surfaces. - ADR-CP-04 - CP-13 receipt re-download authenticated by a server-side ephemeral
cp_readonly_sessionsrow + HttpOnly cookie, IP+UA-hash bound, 5-min sliding TTL bounded by parent link's 72 h. Why: trivially revokable, no JWT/key surface, mirrors main session pattern, keeps AUTH out of post-revoke business. - ADR-CP-05 - Schema frozen v0.0.0 → V1 (no
ALTER TABLEbetween sub-versions); unused columns nullable with defaults. Why: mirrors AUDIT-ADR-03; rolling forward sub-versions never blocks on DDL.
Open Questions (carried)¶
- CP-OQ-01 - CIMA Reg 01-24 article-anchored mandatory fields ⇒ lawyer.
- CP-OQ-02 - Probative value of un-pre-checked checkbox consent ⇒ lawyer.
- CP-OQ-03 - Receipt PDF retention + integrity scheme ⇒ PAY ARCHITECT.
- CP-OQ-05 - Anonymous mandataire receipt acceptability ⇒ lawyer.
- CP-OQ-06 - Refund/dispute UI ⇒ DESIGNER; default "operational only".
- CP-OQ-07 - Mentions-légales content version captured at consent-tick time (locked default).
Cross-module questions raised here and closed in PROGRESS.md:
- OQ-X3 (CP leg) - RESOLVED: correlation_id from signed_link, propagated to PAY initiation + every audit event.
- OQ-X7 - RESOLVED: CP publishes customer.session_opened Spring event in CP-001 (v0.0.0).
End of customer-portal architecture V1 (with V0 envelope).