Aller au contenu

Customer Portal (CP) - Architecture V1 (with V0 envelope)

Module: customer-portal - prefix CP - 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 in docs/architecture/PROGRESS.md are 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 tenantId as the first parameter; every JdbcClient query carries tenant_id = :tenantId in the WHERE clause. SonarQube custom rule + integration test rejects any CP query lacking the predicate (see §13).
  • customer_session_id derived solely from the cookie; never from query string or header. The cookie-bound row carries its own tenant_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: true response 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-Info header.
  • Configuration: Spring Boot 4 ApiVersionConfigurer bean enables header-based versioning globally in CustomerPortalConfiguration.
  • 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 the X-API-Version header. Raw fetch('/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 RequestCondition overhead.

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 as 185 000 XOF.
  • Tenant logo URL points to a CDN-cached image (TENANT-owned).
  • ses.exp lets 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 WhatsApp 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. 1850001 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 v integer (the payload schema version, independent of the HTTP header X-API-Version); clients warn-then-reload on major v bump.
  • 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 + strong ETag.
  • 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_code denormalized - picks up SN/BJ values without schema change.
  • No global UNIQUE on tenant-scoped fields without tenant_id prefix.
  • 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).

  • 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: REISSUED is a CP-distinguishing revoke reason (R-CP-028).
  • 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.verifyAndOpen succeeds. 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_attempts row (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 while inflight_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 local payment_attempts projection.
  • 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
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é."
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 on TenantSettingsChanged).
  • 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 3G profile (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_sessions and payment_attempts rows 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_last4 snapshot - populated from PAY at attempt creation time only; historical rows stay NULL.

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.
  • 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 AUTH SignedLinkService.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_versions migrations.
  • Cross-module: publish customer.session_opened Spring event (OQ-X7 closure).
  • UI: WelcomePage adapter to real /portail/{token} route, new ContractListPage (route /portail/session/contrats), debounced selection with useDebouncedCallback, network-failure banner using useConnectivity.
  • 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), PaymentConfirmed listener updates projection, audit emissions for summary_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) - /payments initiation, idempotency, redirect, PaymentConfirmed listener.
  • CP-002c (S) - receipt screen + /payments/{ref}/recus + CP_RECU_DOWNLOADED.
  • Files: backend listener PaymentEventListener, frontend PaymentRedirect.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 / PaymentFailed listeners; mapped reason table; phone-last-4 column on payment_attempts populated from PAY response.
  • UI: PendingPaymentPage with TanStack Query refetchInterval 5 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.
  • 72 h expires_at enforced in customer_sessions; AUTH cooperates.
  • payment_attempts.attempt_index rendered 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.
  • API: GET /api/portail/{token}/recus (CP-13 entry), GET /api/portail/session/mentions-legales.
  • cp_readonly_sessions row + 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_session cookie (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_sessions row 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_sessions row + 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 TABLE between 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).