Aller au contenu

Customer Portal (CP) - PRD + FSD V1

Module: customer-portal - story prefix CP Plane: Application (com.altarys.papillon.pcs.customer) Version target: V1 (with explicit V2 schedule notes) Country scope: CI only in V1; design preserves multi-country posture for V2. Authored by: ANALYST session, 2026-04-30. Source legal audit: docs/legal/audit/memo-conformite-CI-v2-audit.md (audité le 30/04/2026 - disposition LEGAL REVIEW REQUIRED). Mémo source : docs/legal/memo-conformite-CI-v2.md (Section F3 pour CP).

Legal status discipline (memo v2) - toute obligation INCERTAIN - avocat requis bloque l'implémentation des stories qui en dépendent et impose une note [LEGAL-REVIEW] dans la spec/story. Toute obligation PRÉLIMINAIRE déclenche un avertissement + [LEGAL-REVIEW]. La conformité ne se déduit jamais d'un statut INCERTAIN. Items à surveiller pour CP (memo F3 + E) - magic link à usage unique admis = VÉRIFIÉ (CIMA 001-2024 art. 26 + Loi CI 2013-546 arts. 5 + 8). Double-clic obligatoire = VÉRIFIÉ (Loi 2013-546 art. 20). Droit de rétractation 14 j affiché AVANT paiement = VÉRIFIÉ (CIMA art. 24). Mentions précontractuelles = VÉRIFIÉ (CIMA arts. 22-23 + Loi 2013-546 art. 19). Qualification civile exacte du magic link comme consentement opposable en droit ivoirien = INCERTAIN - avocat requis (red flag #5 audit). Niveau de signature requis pour modification substantielle (art. 9 Loi 2013-546) = INCERTAIN -> V1 restreint au renouvellement à l'identique uniquement ; tout dépassement de scope (modification de garanties, montant, durée) impose [LEGAL-REVIEW] et bloque la story.


V0 Envelope Scope (ajout 2026-05-21)

Lire docs/prd/prd-v0.md § 4 (ligne CP) et § 8 avant toute story V0. Ce PRD reste le contrat V1. Le V0 coupe FROM cette spec ; chaque sous-version V0 est additive et empile vers la cible V1.

CP dans le V0 (per prd-v0.md § 4 + § 8) :

Sous-version Périmètre vs V1 PRD Story IDs
[V0.0.0] Landing signed-link + liste des contrats. Pas de paiement. Auth = signed-link seul (pas de challenge DOB/RCCM). Sélection contrats = all-or-none. Pas d'expiration de lien (résumabilité Slow 3G). Anti-replay = signature uniquement (pas d'attempt counter). Bundle critical path < 200 KB gzipped, mobile-first 360px, FR-only. Form draft = état serveur via réutilisation du lien. CP-001
[V0.0.1] UI paiement live (intègre PAY-001 via APIs). Webhook + réconciliation. Form draft persisté serveur entre tentatives. CP-002
[V0.0.2] Capture identité phone last-4 au paiement (capture customer_type, pas vérification d'identité complète). Toggle per-contrat introduit. Polling de statut paiement + retry manuel. CP-003
[V0.0.3] Lazy-load des détails de paiement (optimisation bundle). CP-004
[V0.1.0] Expiration lien 72h + attempt counter + rate-limiting (hardening prod). CP-005
[V1] Spec V1 complète telle qu'écrite ci-dessous (challenge DOB/phone/RCCM, anti-replay complet, sélection granulaire complète). -

Verrouillé V0.0.0 onwards (cf. prd-v0.md § 4 ligne CP, MC-4 + MC-6) : mobile-first 360px, payload < 200 KB gzipped (critical path = landing + liste contrats ; paiement lazy-loaded), UI FR-only, pas de localStorage côté client (contexte low-bandwidth ; le serveur tient le draft).

Items légaux applicables dès V0 (mêmes contraintes que V1, le V0 n'allège PAS la conformité) : - Renouvellement à l'identique uniquement (toute modification de garanties / montant / durée bloque la story, INCERTAIN - avocat requis). - Double-clic, mentions précontractuelles, droit de rétractation 14j affichés AVANT paiement = obligatoires dès v0.0.1 (première sous-version avec paiement). - Magic-link comme consentement opposable reste INCERTAIN - avocat requis ; [LEGAL-REVIEW] requis sur toute story CP qui s'appuie dessus.


Part A - Product Requirements

A.1 Executive Summary

The customer-portal module owns the end-to-end customer-facing renewal journey on the platform - from the moment AUTH has authenticated the customer (signed link + DOB/RCCM, AUTH-CP-02/03) through to a downloadable payment receipt - and every fallback state that protects the customer when the network drops, the provider stalls, or the broker re-issues the link.

It does NOT own: - the signed-link issuance, verification, lockout, or re-issuance - those belong to AUTH (identity-prd.md §AUTH-CP-*); - the merchant-payment provider integration, idempotency, webhooks, reconciliation, or receipt-PDF generation - those belong to PAY (separate forthcoming PRD; this PRD declares the contract); - the SMS/WhatsApp delivery primitive - that belongs to NOTIF (notifications-prd.md); - the contract data ingestion or correction workshop - that belongs to ING (ingestion-prd.md).

The module solves five concrete problems for V1:

  1. Give the assured a mobile-first, low-bandwidth screen flow that is usable on a 360 px Android over Slow 3G - initial payload < 200 KB gzipped, < 1 round-trip on the critical path (V1 PRD §30).
  2. Surface the CIMA Reg. 01-24 mandatory information before payment - broker identity, per-contract details, mentions légales, contestation procedure, NOTIF-US-08 cross-border block, pre-payment consent - with an audit-logged consent capture so payment is never initiated without an explicit informed acceptance.
  3. Provide a resumable session: the customer can drop their connection, reload the same signed link hours later within the 72h TTL, and find their selection, their pending payment state, and their receipt exactly where they left off. Server-side state, no service worker, no IndexedDB.
  4. Reconcile a payment whose return is unknown without ever double-charging the customer. CP locks the UI to a polling "vérification en cours" state until PAY resolves the transaction (webhook + status check).
  5. Keep the operator informed of every customer-side event - view, selection, consent, payment initiation, payment outcome, receipt download - by emitting AUDIT events in the field set required by V1 PRD §18.

The customer-portal is a thin stateless React 19.2 surface backed by a Spring Modulith application-plane module that calls AUTH (session validation), TENANT (broker identity + agency phone + customisation), the contract repository, PAY (transaction orchestration + receipt artifact), and AUDIT (event emission). All state that survives a reload is server-side; the client holds only ephemeral form state plus a small per-screen render cache.

A.2 Personas

Persona Side Auth Notes
End customer (individual) [CUSTOMER] Signed link + DOB (AUTH-CP-02) Receives WhatsApp/SMS/email, lands on portal, pays for one or more expiring contracts in their name. Likely on entry-level Android, slow 3G, expensive data.
End customer (mandataire of legal entity) [CUSTOMER] Signed link + RCCM (AUTH-CP-03) Anonymous mandataire in V1 - whoever holds the RCCM. Pays for contracts of the legal entity. Same UI flow as individual; copy is adapted (raison sociale).
Branch operator [OPERATOR] Keycloak + MFA Indirect persona - never uses the customer portal but is the consumer of the events it emits and the receiver of customer support escalations (tel: / wa.me/...). Owns re-issuance via AUTH-CP-08 which can interrupt a customer mid-flow.
Broker / cabinet admin [ADMIN] Keycloak + MFA Indirect persona - supplies through TENANT the static "Mentions légales / CGU" content rendered by CP, plus broker identity + agency phone displayed throughout.

A.3 User Stories (with Given/When/Then ACs)

Story IDs are narrative pointers (CP-XX). The PRODUCT_OWNER session may renumber when decomposing into the CP-NNN index file. Stories are written for the customer side unless flagged [BACKEND] or [OPERATOR].

Landing & contract list

CP-01 - Customer lands on the portal after auth [V1] [CUSTOMER]

As an authenticated customer, I want to see the contracts up for renewal so I can decide what to pay.

  • Given AUTH has just opened a customer session for my signed link (post-AUTH-CP-02/03 success), when the contract list page renders, then I see (a) the broker identity block (logo, commercial name, agency phone, support email, agrément number) sourced from TENANT and locked to AUTH-NFR-10, (b) a list of all eligible contracts on this link with branche, n° police, période couverte, échéance, prime, (c) an "Expand" affordance per row to reveal garanties + exclusions principales (free text from ingestion), (d) a persistent footer link "Mentions légales et données personnelles", (e) a click-to-call header chip and (when tenant.whatsapp_business_phone is set) a click-to-WhatsApp chip. The list-load emits customer_contract_list_viewed to AUDIT.
  • Given a contract on my link has been settled or invalidated since the link was issued (e.g. operator marked it paid_offline, file cancelled, contract expired), when the list is queried, then the resolved (server-side) row set excludes it; the page shows a soft notice "Certains contrats ne sont plus éligibles, votre liste a été mise à jour." if any rows were dropped vs. the campaign-time set.
  • Given ALL eligible contracts on the link have been paid (in this session or a prior session), when I open the link, then I see a friendly "Vous avez déjà réglé toutes vos échéances pour cette période." page with a per-contract "Télécharger le reçu" button (CP-13) - even though AUTH has auto-revoked the link's authentication state per AUTH-CP-07, CP keeps a read-only receipt-access surface using a short-lived signed receipt URL.

CP-02 - Customer expands a contract row to read the regulated information [V1] [CUSTOMER]

As a customer, I want to read the basic terms of each contract before I pay, so I act with informed consent.

  • Given a contract row is collapsed, when I tap the row, then it expands to show: branche, n° police, période couverte, prime, garanties principales (free text from ingestion), exclusions/limites principales (free text from ingestion), and a link "Plus de détails - Mentions légales" that opens the static page (CP-12). Expansion does NOT trigger a network call (the data is already in the list payload).
  • Given the ingested contract row has a missing garanties or exclusions free-text field, when I expand the row, then the missing block is replaced by the French copy "Détails non fournis. Demandez à votre conseiller." - never empty. The list-load that surfaces the missing data emits customer_contract_details_missing to AUDIT (signal for the cabinet that ingestion needs richer data).

Selection & summary

CP-03 - Customer selects which contracts to pay [V1] [CUSTOMER]

As a customer who cannot afford to renew everything this month, I want to choose which contracts to pay so I can pay what I can.

  • Given the contract list is rendered, when I check or uncheck a contract row, then the selection is debounced (~500 ms) and persisted server-side against my customer session (CP-23) so a reload restores it; the running total updates in a sticky footer with XOF formatting (15 000 XOF - space as thousands separator) and the active count.
  • Given I have at least one contract selected and the running total > 0 XOF, when the sticky footer is in view, then the primary action button reads "Payer N contrat(s) - TOTAL XOF" and is enabled. With zero selection, the button is disabled and a hint reads "Sélectionnez au moins un contrat à régler.".
  • Given V1 enforces all-or-nothing per contract, when I look at a selected row, then there is no per-contract amount input - the contract is either paid in full or deselected. Partial-amount-per-contract is out of V1 scope (V2 + tenant-configurable).

CP-04 - Customer reviews the summary before payment [V1] [CUSTOMER]

As a customer, I want a clear summary screen showing what I am about to pay and what it commits me to.

  • Given I tap "Payer" on the contract list, when the summary screen renders, then it shows: (a) one row per selected contract with branche, n° police, période couverte, prime, (b) the aggregated total in XOF, (c) the broker identity block (header) and mentions légales link (footer), (d) a single un-pre-checked consent checkbox "Je confirme avoir pris connaissance des conditions des contrats sélectionnés et des mentions légales du cabinet.", (e) a primary action "Payer TOTAL XOF par mobile money" disabled until the checkbox is ticked.
  • Given between the contract-list screen and the summary screen the eligible-contract list has changed (operator out-of-band, server-side refresh), when the summary opens, then the server re-queries eligibility and presents only currently-eligible items; if the running total or set differs from the selection, the page shows "Votre sélection a changé : voici le détail à jour." with a button to confirm or go back.
  • Given I tick the consent checkbox, when I tick, then an audit event customer_consent_captured is emitted with consent_content_version (the version hash of the mentions-légales block tied to the tenant + tenant locale). The consent timestamp + version is bound to my customer session and the in-flight transaction reference so it remains provable post-payment.

Payment

CP-05 - Customer initiates a payment [V1] [CUSTOMER] [BACKEND]

As a customer, I want a single tap to pay, so I do not get lost in PSP UI.

  • Given consent is captured and the summary set is fresh, when I tap "Payer", then CP calls PAY with {customer_session_id, selected_contract_ids[], idempotency_key}; PAY returns a redirect URL to the merchant provider's hosted payment page (Orange Money / Wave / MTN MoMo, per tenant country_code + provider configuration); CP responds to the click within 1.5 s p95 with a redirect.
  • Given PAY successfully starts the transaction, when the redirect happens, then CP records the in-flight transaction reference + provider on the customer session; AUDIT receives customer_payment_initiated with transaction_reference, provider, amount_total_minor, currency=XOF, contract_ids[].
  • Given PAY fails to start the transaction (provider unreachable, 5xx, timeout > 10 s), when the redirect cannot happen, then the customer sees "Impossible de démarrer le paiement. Réessayez ou contactez le cabinet." with a "Réessayer" button + click-to-call. AUDIT receives customer_payment_initiation_failed with the upstream reason code.

CP-06 - Customer returns from the provider on success [V1] [CUSTOMER]

As a customer, I want to land back on the portal with proof that my payment went through.

  • Given the provider redirects me back to cp-return-url with a transaction reference, when the page loads, then CP queries PAY for the transaction status. IF status is CONFIRMED THEN render the reçu screen (CP-07). IF status is PENDING THEN render the "vérification en cours" screen (CP-08). IF status is FAILED THEN render the failed-payment screen (CP-09).
  • Given I lose the provider redirect (browser closed, network drop right at return), when I re-open the same signed link, then CP detects an in-flight transaction on my session and routes me directly to whichever screen matches the current PAY status - without restarting selection.

CP-07 - Customer sees the reçu after payment confirmation [V1] [CUSTOMER]

As a customer, I want a downloadable proof of payment immediately after the transaction succeeds.

  • Given PAY status is CONFIRMED, when the reçu screen renders, then I see (a) a green success banner "Paiement reçu - référence XXXXXX", (b) a per-contract breakdown showing each paid contract + amount + a "Télécharger le reçu" button per contract (CP fetches a short-lived signed object URL from PAY, MinIO-backed), (c) a contestation banner "Pour toute contestation, contactez le cabinet sous 30 jours au [téléphone agence].", (d) the broker identity block (header) and the click-to-call / click-to-WhatsApp chips.
  • Given I tap "Télécharger le reçu" for a contract, when the download starts, then AUDIT receives CP_RECU_DOWNLOADED with transaction_reference, contract_id. The signed object URL is single-use within a 5 min window.
  • Given PAY confirmed the payment, when PAY emits PaymentConfirmed, then NOTIF dispatches a confirmation message via SMS AND WhatsApp (when WA is on file at issuance time) carrying the reçu re-download link and reference. (Cross-module dependency: NOTIF v2 (2026-04-30) implements NOTIF-US-11 - Transactional payment-receipt push. CP consumes the NOTIF transactional template cp_payment_receipt on the channels the customer was reached on. See NOTIF PRD §A.3 NOTIF-US-11 and §B.7 row for the integration contract. CP-OQ-04 is closed.)

CP-08 - Customer in pending state ("vérification en cours") [V1] [CUSTOMER]

As a customer whose payment status is unknown, I want a clear holding screen so I do not pay twice.

  • Given PAY status is PENDING (provider didn't return, webhook hasn't arrived, or status check is inconclusive), when the page loads, then I see "Paiement en cours de vérification…" with a spinner, the in-flight transaction reference + amount + provider, and the message "Cela peut prendre jusqu'à quelques minutes. Ne fermez pas cette page si possible." The contract list is locked read-only behind this screen - no new payment can be initiated.
  • Given the page is open, when the screen mounts, then CP polls PAY status every 5 s for up to 60 s; status changes to CONFIRMED route to CP-07, FAILED route to CP-09. After 60 s with status still PENDING, polling slows to every 30 s and a copy update appears: "Cela prend plus de temps que prévu. Vous recevrez un message dès que le paiement est confirmé. Vous pouvez fermer cette page."
  • Given I close the page and re-open the signed link minutes or hours later (within the 72h TTL), when the link is resolved, then I land back on this same screen as long as the transaction is still in PENDING. Once PAY resolves to CONFIRMED or FAILED, the next click routes accordingly.
  • Given the in-flight transaction stays PENDING beyond PAY's reconciliation timeout (configured by PAY, e.g. 30 min), when PAY flips it to EXPIRED_PENDING, then CP shows "Le paiement n'a pas pu être confirmé. Aucun montant n'a été prélevé. Vous pouvez réessayer." + "Réessayer" button. (PAY guarantees no double-charge via idempotency.)

CP-09 - Customer sees a failed-payment screen [V1] [CUSTOMER]

As a customer whose payment failed, I want a clear next step without losing my selection.

  • Given PAY status is FAILED, when the page renders, then I see "Échec du paiement" + a French human-readable reason mapped from the provider error code (SOLDE_INSUFFISANT, CODE_REFUSÉ, ANNULATION_CLIENT, INDISPONIBLE, AUTRE), the contract set I attempted, and two actions: "Réessayer" (returns to the summary screen with selection preserved) and click-to-call agency. AUDIT receives customer_payment_failed with provider_error_code, mapped_reason.
  • Given I tap "Réessayer", when I confirm consent again on the summary, then a fresh transaction reference + idempotency key are generated by PAY; the previous failed transaction is closed terminally and cannot be re-played.
  • Given retries are uncapped in V1, when I retry repeatedly, then there is no platform-side counter or revoke. (PSP-side rate limits apply naturally.)

Edge cases

CP-10 - Customer link replaced mid-flow ("lien remplacé") [V1] [CUSTOMER]

As a customer whose operator just re-issued my link, I want to know there is a fresh message in my inbox rather than thinking the platform is broken.

  • Given the operator triggered AUTH-CP-08 while I was browsing on the old link, when I next click the OLD link (post-revoke), then AUTH returns the revoke state with reason REISSUED; CP renders dedicated copy "Un nouveau lien vous a été envoyé par votre cabinet. Consultez votre WhatsApp ou SMS le plus récent." + click-to-call agency chip.
  • Given I was on a page when the revoke fired (active customer session dropped), when the next interaction (any XHR) fails with the revoke state, then the page transitions to the same "lien remplacé" screen - never a generic 401/403.

CP-11 - Customer connection drops [V1] [CUSTOMER]

As a customer on a flaky 3G, I want feedback when my connection fails so I know my work is safe.

  • Given the page detects a network failure (XHR timeout, navigator.onLine=false), when the failure persists > 5 s, then a non-intrusive top banner appears: "Connexion instable. Vos choix sont enregistrés." The banner persists until the next successful XHR clears it.
  • Given my selection on the contract list, when any selection mutation occurs, then the change is debounced (~500 ms) and POSTed to the customer-session selection endpoint; on success the local "saved" indicator dot turns green, on failure it stays amber and a queued retry runs every 10 s. The selection state is server-authoritative; on reload the server's set wins.
  • Given I never get back online within 72h, when my link expires, then AUTH-CP-05 takes over and renders the "lien expiré" screen.

CP-12 - Customer reads "Mentions légales et données personnelles" [V1] [CUSTOMER]

As a customer, I want a single page that gathers the legal content I need.

  • Given I tap the footer link "Mentions légales et données personnelles", when the page opens, then it renders a tenant-customisable static block stitched from: (a) identité du courtier (legal_name + commercial_name + agrément + RCCM + adresse, sourced from TENANT), (b) procédure de réclamation / contestation, (c) notice de protection des données personnelles + sub-processors, (d) the canonical NOTIF-supplied cross-border block (NOTIF-US-08, "Mentions sur les transferts hors UEMOA"), (e) CGU + conditions du service de paiement.
  • Given the tenant has not yet supplied custom content, when the page is opened, then a platform-default French version is rendered with a tenant-aware substitution of name + agrément + agency phone - never a "lorem ipsum" or empty section.
  • Given I open this page, when it renders, then AUDIT receives customer_mentions_legales_viewed with the content_version. The same version is the one used by R-CP-006 consent capture.

CP-13 - Customer self-service receipt re-download after the link is auto-revoked [V1] [CUSTOMER]

As a customer who already paid and comes back days later, I want to retrieve my reçus without calling the agency.

  • Given AUTH has auto-revoked the link because every contract is paid (AUTH-CP-07), when I re-click the same link within the 72h TTL, then CP overrides the generic AUTH revoke copy and renders the "déjà réglé" page (CP-01.3) with per-contract download buttons.
  • Given the link's TTL has elapsed, when I click, then AUTH-CP-05 owns the response (generic "lien expiré"); CP cannot intervene.

Operator-facing surface (indirect)

CP-14 - Operator sees the customer's portal-side timeline on the file detail [V1] [OPERATOR]

As an operator, I want to see what the customer did so I can help them.

  • Given I open a file detail in the operator console, when the timeline panel renders, then I see a chronological list of every CP-emitted AUDIT event for that file: list-viewed, selection-changed, consent-captured, payment-initiated, payment-status-pending, payment-confirmed, payment-failed, reçu-downloaded, mentions-légales-viewed.
  • Given I see a customer_payment_failed event with mapped_reason=SOLDE_INSUFFISANT, when I look at the file's status badge, then the badge reflects the latest status (paiement échoué) so I can prioritise a follow-up call.

A.4 Non-Functional Requirements

ID Requirement Side
CP-NFR-01 Customer portal initial payload (HTML + critical CSS + critical JS) < 200 KB gzipped on every screen, usable on Slow 3G profile (V1 PRD §30.2). React 19.2 bundle aggressively code-split per route. [CUSTOMER]
CP-NFR-02 Mobile-first 360 px portrait; touch targets ≥ 44 px; no hover-only interactions; works on Android 8+ default browser. [CUSTOMER]
CP-NFR-03 French only in V1 (fr-FR). Date format JJ/MM/AAAA. Phone display +225 XX XX XX XX. Currency XOF with space thousands separator and zero decimals (15 000 XOF). [CUSTOMER]
CP-NFR-04 Server-side selection persistence: every selection mutation is POSTed within 1 s of the user action; on reload within the 72h TTL, server-side selection state is rendered authoritative. No service worker, no IndexedDB, no client-side mutation queue (CLAUDE.md rule 11). [CUSTOMER]
CP-NFR-05 Network-failure banner appears within 5 s of detected failure; clears on next successful XHR. Banner text and offline indicator are accessible (role=status, aria-live=polite). [CUSTOMER]
CP-NFR-06 Pending-payment auto-poll: every 5 s for the first 60 s, every 30 s thereafter, no upper bound while the in-flight transaction has not been resolved by PAY. Polling stops on tab close; resumes on reload. [CUSTOMER]
CP-NFR-07 Reçu PDF download is served via a short-lived (≤ 5 min) signed object URL produced by PAY against MinIO; CP never proxies the PDF body. [CUSTOMER]
CP-NFR-08 Every event listed in §B.3 emits to AUDIT with the V1 PRD §18 base fields PLUS the CP-specific fields. [BACKEND]
CP-NFR-09 Slow-3G smoke test: every customer-facing story is verified on a throttled Slow 3G profile (RTT 2 s, 400 Kbps down) before declaring done (CLAUDE.md rule 12). [CUSTOMER]
CP-NFR-10 The CP module is stateless - every survivable state is on the customer session (in PostgreSQL + a Redis fast-path for selection / poll-state). On a server restart, no customer-side data is lost. [BACKEND]
CP-NFR-11 Tenant-status guard (R-OP-017 of AUTH) is honoured on every CP endpoint; CP does NOT cache tenant status itself - it relies on AUTH's verifier to short-circuit suspended/deleted tenants. [BACKEND]
CP-NFR-12 Browser back/forward navigation must not produce a stale UI: on every screen mount, CP re-reads the customer session state server-side. The Cache-Control headers on CP HTML responses are no-store. [CUSTOMER]
CP-NFR-13 The portal renders the broker identity surface (AUTH-NFR-10 fields) on every screen, including pending and error screens. No screen ever shows a "Papillon" brand without the broker brand. [CUSTOMER]
CP-NFR-14 XHR endpoints from CP enforce a hard server-side timeout of 15 s (PAY initiation) / 5 s (status polling) / 3 s (selection persistence) so a stalled provider does not lock a worker. [BACKEND]
CP-NFR-15 The customer-session row carries an inflight_transaction_id foreign key (nullable). At most ONE non-terminal transaction may be linked at any time - enforced by partial unique index. [BACKEND]

A.5 V1 vs V2 vs V3

Capability V1 V2 V3
Renewal-payment journey end-to-end
Hosted-redirect to PSP ✅ + provider-choice UI per tenant ✅ + embedded checkout option
Single aggregated transaction across selected contracts
All-or-nothing per contract (no per-contract partial amount) configurable per tenant + installment plans ✅ + tenant-defined échéanciers
Pre-payment consent checkbox + content-version capture ✅ + multi-version diff display
Mentions légales / CGU static page ✅ tenant-customisable single block ✅ multi-block + per-product
Server-side selection persistence on the customer session
Pending-payment poll + reconciliation ✅ + push update via WebSocket
Reçu PDF download (PAY-generated, MinIO-stored) ✅ + multi-language re-issuance
Failed-payment retry uncapped configurable cap per tenant
Re-issued-link "lien remplacé" copy
Click-to-call + click-to-WhatsApp agency ✅ + in-portal chat handover
Network-failure banner + offline indicator ✅ + queue-depth indicator
Customer-facing flows beyond renewal payment (claims, attestation, contract change, doc upload) ✅ subset
Broker-uploaded quittance download
Auto-generated provisional attestation d'assurance ❌ (regulatory risk)
Per-contract partial-amount payment
French-only UI ✅ + EN ✅ + multi-locale
Multi-country (XAF + XOF) ❌ CI only ✅ per-country tenant ✅ federated

A.6 Open Questions

ID Topic Resolution required by
CP-OQ-01 CIMA Reg. 01-24 mandatory information surfaces - article anchors. The list rendered by CP-01/04/12 is the V1 working set; an article-anchored list is still pending (Legal-Compliance audit gap #9 + AUTH-OQ-05). May force adding/removing fields in the per-contract panel. Before V1 production launch - qualified lawyer.
CP-OQ-02 Probative value of digital consent captured by CP-04 (un-pre-checked checkbox + content-version + audit timestamp) - does this satisfy CIMA Reg. 01-24's "proof of acceptance" requirement, or is a "reinforced" path (OTP, Level 2) required for some risk classes? (Legal audit gap #1 + #12.) Before V1 production launch - qualified lawyer.
CP-OQ-03 Reçu PDF retention and integrity. PAY stores reçus in MinIO; the retention period and integrity scheme (hash chain, immutability) are owned by PAY but consumed via CP. CP needs PAY's contract finalised before launch. PAY PRD authoring.
~~CP-OQ-04~~ ~~NOTIF rework for payment-receipt push (CP-07).~~ CLOSED 2026-04-30 by NOTIF v2 (NOTIF-US-11 - Transactional payment-receipt push). CP consumes the NOTIF transactional template cp_payment_receipt. Closed. See NOTIF PRD §A.3 NOTIF-US-11.
CP-OQ-05 Anonymous mandataire identity and receipt download. AUTH-CP-03 authenticates an anonymous holder of the RCCM. The reçu carries the legal entity's name + the platform reference but no human signatory. Is this acceptable as proof-of-payment under CIMA Reg. 01-24 + ARTCI? (Legal audit gap #1.) Before V1 production launch - qualified lawyer.
CP-OQ-06 Refund / dispute flow. V1 PRD §16.4 implies no refunds; CP-07 surfaces a contestation banner. Is the contestation flow purely operational (call agency) in V1, or does CP need a "demande de remboursement" form? DESIGNER session, but defaults to "operational only" until proven otherwise.
CP-OQ-07 Multi-tenant content versioning for "Mentions légales". When a tenant edits their static block mid-cycle, what version does CP-04 capture in consent_content_version for in-flight customer sessions - the version at session-open time, or at consent-tick time? Default in V1: at consent-tick time (latest published). TENANT module rework.

A.7 Temporarily Validated

These are decisions implemented as-is in V1 pending the resolutions above:

  • CP-TV-01 - The CP-01/04/12 "mandatory information" surface (broker identity header, per-contract details panel, mentions légales footer link, pre-payment consent checkbox, contestation banner) is treated as the working V1 set, on the assumption it satisfies CIMA Reg. 01-24 (CP-OQ-01).
  • CP-TV-02 - Pre-payment consent is captured as a single un-pre-checked checkbox bound to the tenant content version; this is treated as sufficient digital proof-of-acceptance for V1 (CP-OQ-02).
  • CP-TV-03 - Failed-payment retries are uncapped on the platform side in V1; PSP-side rate limits and operator-side observation are the only controls. No per-link or per-customer counter. V2 may add a configurable cap.
  • CP-TV-04 - Pending-payment polling cap: CP polls indefinitely while PAY reports PENDING, slowing to 30 s after 60 s. PAY's EXPIRED_PENDING terminal state is the sole stop signal.
  • CP-TV-05 - Receipt PDF is the sole customer-downloadable artifact in V1. Broker quittance and attestation are V2.
  • CP-TV-06 - Customer-session state is stored in the application-plane DB (PostgreSQL), with a Redis fast-path for selection state and poll-cache. ARCHITECT may revise - CP only requires the durability + isolation guarantees.
  • CP-TV-07 - Click-to-WhatsApp uses tenant.whatsapp_business_phone (TENANT field). If absent for a tenant in V1, the WA chip is hidden. No fallback to a personal number - see CLAUDE.md rule 8.
  • CP-TV-08 - The "Mentions légales" page is read-only on the CP side; tenant editing surface lives in TENANT-PRD. CP just renders the latest published version.
  • CP-TV-09 - The CP-01.3 / CP-13 "déjà réglé" surface uses a CP-side cookie-less, IP-and-UA-shaped short read-only session (signed against the same secret as the link) so the customer can re-download even after AUTH revoked the auth state - bounded to the original 72h TTL of the parent link.

Part B - Functional Specifications

B.1 Functional Rules (numbered)

Landing & contract list

  • R-CP-001 - On a customer-session-open event from AUTH (post-AUTH-CP-02/03 success), CP queries the contract repository for the (campaign_id, customer_file_id) pair and returns ONLY contracts that are: (a) eligible at query time (status PENDING_RENEWAL), (b) not paid in any session, (c) not flagged EXCLUDED_BY_OPERATOR, (d) prime > 0.
  • R-CP-002 - IF the resolved set differs from the campaign-issuance set THEN render the "votre liste a été mise à jour" notice; ELSE render normally. Set comparison is per contract_id.
  • R-CP-003 - Every list render emits customer_contract_list_viewed with customer_session_id, contract_ids[], inflight_transaction_id (nullable).
  • R-CP-004 - IF the resolved set is empty AND there is an in-flight or terminal transaction on the session THEN route to CP-08 / CP-07 / CP-09 (whichever PAY status applies). IF the resolved set is empty AND no transaction has been initiated AND every contract was paid in a prior session THEN render the "déjà réglé" surface (CP-13) with per-contract reçu download buttons.
  • R-CP-005 - Per-contract details (garanties, exclusions) are sourced from ingested fields. Missing fields render as "Détails non fournis. Demandez à votre conseiller."; emit customer_contract_details_missing once per session per contract (deduped server-side).

Selection

  • R-CP-006 - Selection is a Set<contract_id> server-side state. Any client mutation is debounced ~500 ms and POSTed to PUT /cp/sessions/{id}/selection. Server-side validates each contract_id against the latest eligible set; rejected ids are stripped and a 200 response surfaces them in the body.
  • R-CP-007 - IF a contract becomes ineligible after selection (e.g. operator marked it paid offline) THEN it is silently removed from the server-side set on next refresh; the client UI re-queries on next screen entry per CP-NFR-12.
  • R-CP-008 - V1 forbids per-contract partial amount. Amount per contract = contract.prime (frozen at session-open). The UI MUST NOT expose an amount input.
  • R-CP-009 - On entering the summary screen, CP re-queries the eligible set (R-CP-001) and intersects with the persisted selection. IF the intersection is empty THEN redirect to the contract list with copy "Aucun contrat sélectionné n'est plus éligible." IF the intersection differs from the persisted selection THEN render with the "votre sélection a changé" notice and require an explicit confirm before the consent checkbox unlocks.
  • R-CP-010 - Consent capture is a single boolean field on the customer-session payment-attempt row. IF the box is unticked THEN the "Payer" button is disabled. The box is NEVER pre-ticked and NEVER auto-ticks on reload - the customer must tick it for every fresh attempt (a refused payment requires re-consent on retry).
  • R-CP-011 - On consent tick, emit customer_consent_captured with customer_session_id, content_version, contract_ids[], total_amount_minor, currency=XOF, ticked_at. Content version is read at tick time per CP-OQ-07 default.

Payment initiation

  • R-CP-012 - On "Payer" click: CP generates an idempotency_key (UUIDv4), opens a payment-attempt row tied to the customer session, then calls PAY POST /pay/transactions with {tenant_id, customer_file_id, customer_session_id, contract_ids[], amount_total_minor, currency, idempotency_key, return_url, cancel_url}. PAY's response is {transaction_reference, redirect_url} or a typed failure.
  • R-CP-013 - Idempotency invariant: IF the customer session already has a non-terminal payment-attempt row THEN a second click of "Payer" must NOT initiate a second transaction - it must surface CP-08 (vérification en cours) and re-poll the existing transaction. This is enforced by CP-NFR-15.
  • R-CP-014 - IF PAY returns an error within 10 s THEN CP renders CP-09's failed surface with provider_error_code=PAY_INITIATION_FAILED, mapped_reason='Service de paiement indisponible'. IF PAY does not respond within 15 s (CP-NFR-14) THEN CP closes the open attempt row terminally with outcome=INITIATION_TIMEOUT, no upstream transaction is assumed to exist, and the customer is shown CP-09.
  • R-CP-015 - The redirect URL returned by PAY MUST be sent as a 303 See Other from CP - never an <a href> click - to ensure no browser-cache replay on back navigation.

Return & status resolution

  • R-CP-016 - On hit of the return URL, CP reads transaction_reference from query parameters AND verifies it against the customer session's in-flight attempt - they MUST match. Mismatch is treated as a tampering attempt: render a generic error and emit customer_payment_return_mismatch with both references for AUDIT.
  • R-CP-017 - CP queries PAY status: CONFIRMED → render CP-07; PENDING → render CP-08; FAILED → render CP-09; EXPIRED_PENDING → render CP-09 with mapped reason EXPIRATION. CANCELLED_BY_USER → render CP-09 with mapped reason ANNULATION_CLIENT.
  • R-CP-018 - IF the customer reaches the return URL without a transaction_reference (e.g. provider redirect malformed) THEN CP routes by the customer session's in-flight transaction status (R-CP-017 against the session-bound reference). NEVER a 4xx page - always a routed CP screen.

Pending state

  • R-CP-019 - On entering CP-08, CP starts polling GET /pay/transactions/{ref}/status every 5 s for 60 s, then every 30 s thereafter, with no upper bound while the status is PENDING. Each poll emits customer_payment_status_polled with transaction_reference, poll_index, status_returned.
  • R-CP-020 - Polling stops when status flips from PENDING to any terminal state OR when the tab is hidden + closed for > 5 min (resume on reload). Polling is per-tab, server-resolved (no SSE/WS in V1).
  • R-CP-021 - IF the customer reloads the link or returns days later within the 72h TTL AND the in-flight transaction is still PENDING THEN CP-08 is rendered automatically (R-CP-004) and polling resumes.

Confirmation & reçu

  • R-CP-022 - On CONFIRMED: CP fetches per-contract reçu URLs from PAY (GET /pay/transactions/{ref}/recus); each URL is short-lived (≤ 5 min). The page renders the reçu list and the contestation banner.
  • R-CP-023 - On reçu download click, emit CP_RECU_DOWNLOADED with transaction_reference, contract_id, recu_object_key. The signed URL is single-use within its 5 min window; expired clicks re-fetch a fresh URL.
  • R-CP-024 - Post-confirmation NOTIF dispatch (cross-module): on PAY's PaymentConfirmed, NOTIF dispatches a transactional template cp_payment_receipt carrying the reçu re-download deep link (a stable per-transaction URL) on the channels the customer was reached on (SMS + WA). Implemented by NOTIF-US-11 (NOTIF v2, 2026-04-30); CP consumes the published template; PAY orchestrates dispatch via PaymentConfirmed.

Failed payment

  • R-CP-025 - Provider error codes are mapped to French human-readable reasons via a static lookup table (SOLDE_INSUFFISANT, CODE_REFUSÉ, ANNULATION_CLIENT, INDISPONIBLE, EXPIRATION, AUTRE). Unknown codes map to AUTRE with French copy "Le paiement n'a pas abouti. Réessayez ou contactez le cabinet."
  • R-CP-026 - On retry click: CP (a) marks the failed payment-attempt row terminal, (b) returns the customer to the summary screen with the persisted selection intact, (c) requires a fresh consent tick (R-CP-010), (d) generates a new idempotency_key on the next "Payer" click. The previous failed transaction is NEVER reused.
  • R-CP-027 - Retry counter is informational only (audited via payment_attempt_index); no platform-side cap in V1.
  • R-CP-028 - IF AUTH returns {state=REVOKED, reason=REISSUED} THEN CP renders the dedicated "lien remplacé" copy (CP-10), NOT the generic AUTH revoke copy. This applies to a fresh click of the old link AND to an XHR mid-session that fails after the revoke fired.
  • R-CP-029 - IF AUTH returns {state=REVOKED, reason=ANY OTHER} THEN CP defers to AUTH's generic revoke copy (R-CP-006 of identity-prd) with the broker identity surface.

Connectivity

  • R-CP-030 - A network-failure banner appears within 5 s of the first detected XHR failure (timeout, network error, navigator.onLine=false, 5xx). The banner persists until the next successful XHR. The banner copy is "Connexion instable. Vos choix sont enregistrés."
  • R-CP-031 - Selection persistence (R-CP-006) retries with exponential backoff capped at 30 s while offline. The "saved" indicator dot is amber while a sync is pending and green on success.
  • R-CP-032 - On 401/403 responses from any CP XHR, the page hard-reloads its parent route - AUTH's verifier owns the new state (expired, revoked, lockout, tenant-suspended). CP NEVER tries to handle re-auth in-line.

Self-service receipt re-download (post auto-revoke by AUTH-CP-07)

  • R-CP-033 - When AUTH auto-revokes a link with reason ALL_PAID (AUTH-CP-07 first AC), CP intercepts the next click and renders CP-13 (the "déjà réglé" surface). The customer's identity is asserted by the link's token alone (no challenge replay) - CP issues a CP-side, read-only 5-min session bound to (link_id, IP, UA fingerprint) for the duration of the visit. Each reçu download is a fresh signed URL (R-CP-022).
  • R-CP-034 - The CP-side read-only session is NOT renewable. After 5 min of inactivity, the next interaction re-renders CP-13 from a fresh CP-side session. The 72h parent-link TTL still gates the whole surface.

Multi-tenant + cross-tenant

  • R-CP-035 - Every CP request resolves a TenantContext from the customer session (which AUTH attached at session-open). Cross-tenant access is structurally impossible: the customer_session_id carries the tenant_id, and every repository read is scoped by tenant_id (CLAUDE.md rule 1).
  • R-CP-036 - On TenantSuspended (AUTH-OP-014/017): every active customer session for the tenant is dropped by AUTH; CP requests after that point hit AUTH's tenant-status guard and short-circuit with HTTP 503 + auth.tenant.suspended.customer copy. CP does NOT cache tenant status.
  • R-CP-037 - On AgencyDisabled: CP behaviour is unchanged - a customer whose file belongs to a now-disabled agency CAN still complete a paid renewal on an already-issued link (per AUTH-CP-021 / R-CP-021 of identity-prd). The reçu reflects the agency at file-issuance time.

B.2 State Machines

Customer session (CP-side)

stateDiagram-v2
    [*] --> AUTHENTICATED: AUTH opens session (post AUTH-CP-02/03)
    AUTHENTICATED --> SELECTING: customer lands on contract list
    SELECTING --> SELECTING: selection mutation persisted
    SELECTING --> SUMMARY: tap Payer (selection non-empty)
    SUMMARY --> SELECTING: tap Retour
    SUMMARY --> CONSENTED: tick consent box
    CONSENTED --> SUMMARY: untick consent box
    CONSENTED --> INITIATING_PAYMENT: tap Payer (final)
    INITIATING_PAYMENT --> REDIRECTED_TO_PSP: PAY returned redirect_url
    INITIATING_PAYMENT --> FAILED_INITIATION: PAY error or timeout
    REDIRECTED_TO_PSP --> RETURN_PENDING: customer returns, status=PENDING
    REDIRECTED_TO_PSP --> RETURN_CONFIRMED: customer returns, status=CONFIRMED
    REDIRECTED_TO_PSP --> RETURN_FAILED: customer returns, status=FAILED
    RETURN_PENDING --> RETURN_PENDING: poll, still PENDING
    RETURN_PENDING --> RETURN_CONFIRMED: poll → CONFIRMED
    RETURN_PENDING --> RETURN_FAILED: poll → FAILED or EXPIRED_PENDING
    RETURN_CONFIRMED --> RECEIPT_AVAILABLE: render reçu screen
    RECEIPT_AVAILABLE --> RECEIPT_AVAILABLE: download recu
    RECEIPT_AVAILABLE --> ALL_PAID: AUTH auto-revokes link
    ALL_PAID --> ALL_PAID: re-click within 72h, CP-13
    RETURN_FAILED --> SELECTING: tap Réessayer
    FAILED_INITIATION --> SELECTING: tap Réessayer
    AUTHENTICATED --> LINK_REPLACED: AUTH revoke reason=REISSUED
    SELECTING --> LINK_REPLACED: AUTH revoke mid-flow
    SUMMARY --> LINK_REPLACED: AUTH revoke mid-flow
    CONSENTED --> LINK_REPLACED: AUTH revoke mid-flow
    AUTHENTICATED --> EXPIRED: 72h TTL
    SELECTING --> EXPIRED: 72h TTL
    SUMMARY --> EXPIRED: 72h TTL
    AUTHENTICATED --> TENANT_SUSPENDED: TenantSuspended
    SELECTING --> TENANT_SUSPENDED: TenantSuspended
    SUMMARY --> TENANT_SUSPENDED: TenantSuspended
    LINK_REPLACED --> [*]
    EXPIRED --> [*]
    TENANT_SUSPENDED --> [*]
    ALL_PAID --> [*]: 72h TTL elapsed

Payment-attempt (CP-side, mirrors PAY status)

stateDiagram-v2
    [*] --> CREATED: idempotency_key reserved
    CREATED --> INITIATED: PAY accepted, redirect_url returned
    CREATED --> INITIATION_FAILED: PAY error or timeout
    INITIATED --> PENDING: customer returned, PAY reports PENDING
    INITIATED --> CONFIRMED: customer returned, PAY reports CONFIRMED
    INITIATED --> FAILED: customer returned, PAY reports FAILED
    PENDING --> PENDING: poll, still PENDING
    PENDING --> CONFIRMED: PAY transitions to CONFIRMED
    PENDING --> FAILED: PAY transitions to FAILED
    PENDING --> EXPIRED_PENDING: PAY reconciliation timeout
    INITIATION_FAILED --> [*]: terminal
    CONFIRMED --> [*]: terminal (recu available)
    FAILED --> [*]: terminal
    EXPIRED_PENDING --> [*]: terminal (rendered as FAILED/EXPIRATION)

B.3 Audit Events Emitted by CP

Every event carries V1 PRD §18 base fields (event_id, timestamp, initiator, tenant_id, agency_id, customer_file_id, result) PLUS the CP-specific fields below.

Event Side CP-specific fields
customer_contract_list_viewed [CUSTOMER] customer_session_id, contract_ids[], inflight_transaction_id (nullable)
customer_contract_details_missing [BACKEND] customer_session_id, contract_id, missing_fields[]
customer_selection_changed [CUSTOMER] customer_session_id, added_contract_ids[], removed_contract_ids[], selection_size
customer_summary_viewed [CUSTOMER] customer_session_id, contract_ids[], total_amount_minor, currency
customer_consent_captured [CUSTOMER] customer_session_id, content_version, contract_ids[], total_amount_minor, currency, ticked_at
customer_mentions_legales_viewed [CUSTOMER] customer_session_id, content_version
customer_payment_initiated [CUSTOMER] customer_session_id, transaction_reference, idempotency_key, provider, contract_ids[], amount_total_minor, currency
customer_payment_initiation_failed [BACKEND] customer_session_id, idempotency_key, upstream_reason_code
customer_payment_return_mismatch [BACKEND] customer_session_id, expected_reference, received_reference, ip, user_agent
customer_payment_status_polled [BACKEND] transaction_reference, poll_index, status_returned
customer_payment_confirmed [CUSTOMER] transaction_reference, contract_ids[], amount_total_minor, currency
customer_payment_failed [CUSTOMER] transaction_reference, provider_error_code, mapped_reason
customer_payment_expired_pending [BACKEND] transaction_reference
CP_RECU_DOWNLOADED [CUSTOMER] transaction_reference, contract_id, recu_object_key
customer_offline_banner_shown [BACKEND] customer_session_id, trigger_reason (xhr_timeout | navigator_offline | 5xx)
customer_link_replaced_landing [CUSTOMER] customer_session_id, previous_link_id
customer_already_paid_landing [CUSTOMER] parent_link_id (post-AUTH-revoke), reason=ALL_PAID
customer_support_contact_clicked [CUSTOMER] customer_session_id, channel (tel | wa)

B.4 Decision Tables

Customer-portal landing routing

Auth state (from AUTH) Inflight tx state (from PAY) All contracts paid? CP screen rendered
AUTHENTICATED (challenge ok) none no Contract list (CP-01)
AUTHENTICATED PENDING n/a Pending screen CP-08
AUTHENTICATED CONFIRMED (current session) n/a Reçu screen CP-07
AUTHENTICATED FAILED (current session) no Failed screen CP-09
AUTHENTICATED FAILED yes "Déjà réglé" CP-13 (prior session paid the rest)
REVOKED reason=ALL_PAID n/a yes "Déjà réglé" CP-13 (CP-side read-only session)
REVOKED reason=REISSUED n/a n/a "Lien remplacé" CP-10
REVOKED reason=MAX_LOCKOUT_CYCLES n/a n/a AUTH copy "lien désactivé" (no CP override)
EXPIRED n/a n/a AUTH copy "lien expiré"
TENANT_SUSPENDED/DELETED/PENDING n/a n/a AUTH copy 503 generic (no leak)

Provider error code → French mapped reason

Provider error class provider_error_code (canonical) mapped_reason (FR copy)
Insufficient balance SOLDE_INSUFFISANT « Solde insuffisant sur votre compte mobile money. »
Wrong PIN / refused CODE_REFUSÉ « Le code saisi a été refusé par votre opérateur. »
User cancel ANNULATION_CLIENT « Vous avez annulé le paiement. »
Provider unavailable INDISPONIBLE « Le service de paiement est temporairement indisponible. Réessayez. »
Expiration / timeout EXPIRATION « Le délai de paiement est dépassé. Réessayez. »
Tampering / mismatch RETOUR_INCOHÉRENT « Une incohérence a été détectée. Contactez le cabinet. »
Other AUTRE « Le paiement n'a pas abouti. Réessayez ou contactez le cabinet. »

Pending-poll cadence

Elapsed since first poll Cadence Customer copy
0 – 60 s every 5 s « Paiement en cours de vérification… » + spinner
60 s – 30 min every 30 s « Cela prend plus de temps que prévu. Vous recevrez un message dès la confirmation. »
> PAY reconciliation timeout terminal EXPIRED_PENDING route to CP-09

B.5 Data Requirements

Aligned with V1 PRD §27 entities. CP module owns or contributes the following.

customer_sessions (application plane)

  • customer_session_id UUID PK
  • tenant_id UUID NOT NULL - tenant scope (CLAUDE.md rule 1)
  • agency_id UUID NOT NULL
  • customer_file_id UUID NOT NULL - FK to ingested file
  • signed_link_id UUID NOT NULL - FK to signed_links (AUTH-owned)
  • state ENUM {AUTHENTICATED, SELECTING, SUMMARY, CONSENTED, INITIATING_PAYMENT, REDIRECTED, RETURN_PENDING, RECEIPT_AVAILABLE, FAILED, LINK_REPLACED, EXPIRED, TENANT_SUSPENDED, ALL_PAID} NOT NULL
  • selected_contract_ids UUID[] NOT NULL DEFAULT {}
  • consent_content_version TEXT NULL - captured at consent tick
  • consent_ticked_at TIMESTAMPTZ NULL
  • inflight_transaction_id UUID NULL - FK to payment_attempts; partial UNIQUE (customer_session_id WHERE inflight_transaction_id IS NOT NULL) per CP-NFR-15
  • created_at TIMESTAMPTZ NOT NULL
  • last_activity_at TIMESTAMPTZ NOT NULL
  • country_code TEXT NOT NULL - denormalised from tenant for consistency

Indexes: (tenant_id, signed_link_id), (tenant_id, customer_file_id), partial UNIQUE on inflight_transaction_id.

payment_attempts (application plane - CP's view; canonical row owned by PAY)

CP holds a projection of PAY's transaction with the fields it needs for routing without round-trips on every render:

  • payment_attempt_id UUID PK (= PAY transaction id)
  • customer_session_id UUID NOT NULL
  • tenant_id, agency_id, customer_file_id UUID NOT NULL
  • transaction_reference TEXT UNIQUE NOT NULL
  • idempotency_key UUID UNIQUE NOT NULL
  • provider TEXT NOT NULL
  • contract_ids UUID[] NOT NULL - frozen at attempt creation
  • amount_total_minor BIGINT NOT NULL - XOF stored as integer minor units (CLAUDE.md rule 2)
  • currency TEXT NOT NULL DEFAULT 'XOF'
  • status ENUM {CREATED, INITIATED, PENDING, CONFIRMED, FAILED, EXPIRED_PENDING, INITIATION_FAILED} NOT NULL
  • provider_error_code TEXT NULL
  • mapped_reason TEXT NULL
  • confirmed_at TIMESTAMPTZ NULL
  • attempt_index INT NOT NULL DEFAULT 1 - informational; per customer session

Updates flow from PAY events; CP never writes status server-side except CREATED → INITIATED → INITIATION_FAILED for the very first hop.

mentions_legales_versions (read-only projection from TENANT)

  • tenant_id UUID NOT NULL
  • content_version TEXT NOT NULL - content hash or semver from TENANT
  • content_html TEXT NOT NULL
  • published_at TIMESTAMPTZ NOT NULL
  • PK (tenant_id, content_version)

CP reads-only; TENANT publishes new versions on operator edit.

Consumed entities (read-only)

  • signed_links (AUTH)
  • tenants (TENANT) - commercial_name, legal_name, agrement_number, logo_object_key, primary_color, support_email, whatsapp_business_phone, country_code
  • agencies (TENANT) - phone, commercial_name
  • customers, contracts, customer_files (ING)
  • payment_recus (PAY) - read via signed object URLs, never proxied through CP

B.6 Multi-Tenant Implications

  • Tenant isolation (CLAUDE.md rule 1): every CP repository call filters by tenant_id. The tenant_id is read from customer_sessions (which AUTH attached at session-open). CP NEVER trusts any tenant_id derived from URL or query parameters.
  • Both V1 DB profiles apply identically: shared DB / shared schema (default) and BYO-DB (tenant supplies their own PostgreSQL connection). The customer_sessions and payment_attempts tables live in the application-plane DB for the tenant - which, in BYO-DB, is the tenant's connection. The TenantConfigService resolves the right datasource per request (CLAUDE.md "Multi-Tenant Model - V1 DB profiles").
  • Country profile: tenants.country_code determines: PSP provider list (PAY consumes), currency display (V1: XOF only), tax-info-at-payment surface (V1: none for CI; V2 adds CM/GA), legal-content defaults for the "Mentions légales" page (V1: CI defaults). CP reads country_code only via the TenantConfig resolver - no business logic baked.
  • Foreign-subsidiary tenants (V2+): each is a separate tenant, with its own customer-session pool. A customer of an Ivorian tenant CANNOT see contracts of the same legal group's Senegalese subsidiary - separate tenants, separate sessions. Schema requires no migration.
  • Tenant-status lifecycle: every CP endpoint inherits AUTH's tenant-status guard (R-OP-017 of identity-prd). On TenantSuspended, AUTH drops the customer session; CP responses on suspended-tenant traffic short-circuit at AUTH with HTTP 503 + the standard customer copy - CP itself does NOT cache or evaluate tenant status.
  • Agency lifecycle: a disabled agency does NOT block in-flight customer renewals (R-CP-037 + AUTH-R-CP-021). Reçu artifacts reference the agency as it was at file-issuance time.

B.7 Cross-Module Dependencies

Module Consumed (CP needs from them) Exposed (CP provides)
AUTH (identity) Customer session open after AUTH-CP-02/03; revocation states (ALL_PAID, REISSUED, MAX_LOCKOUT_CYCLES); tenant-status guard (R-OP-017); broker-identity surface fields (delegated to TENANT). customer_already_paid_landing, customer_link_replaced_landing audit events for cross-correlation.
TENANT (tenant-admin) commercial_name, legal_name, agrement_number, logo_object_key, primary_color, support_email, whatsapp_business_phone, agency phone, country_code. Static "Mentions légales" content + version. TenantSettingsChanged to invalidate any local cache. None.
ING (ingestion) Contract data: branche, n° police, période couverte, prime, garanties (free text), exclusions (free text), eligibility status updates. None.
PAY (forthcoming) POST /pay/transactions (initiate); GET /pay/transactions/{ref}/status (poll); GET /pay/transactions/{ref}/recus (signed URLs); events PaymentConfirmed, PaymentFailed, PaymentExpiredPending. Idempotency-key contract. Receipt PDF generation + MinIO storage. customer_session_id, contract_ids[], amount_total_minor, idempotency_key on initiation.
NOTIF (notifications) Transactional template cp_payment_receipt for the post-confirmation push, implemented by NOTIF-US-11 (NOTIF v2, 2026-04-30). NOTIF-supplied canonical FR cross-border block (NOTIF-US-08). Trigger on PaymentConfirmed (orchestrated by PAY → NOTIF, CP just consumes the artifacts).
AUDIT (audit-log) None. All events listed in §B.3.
OP (operator-console) None. Customer-side timeline events for the file-detail timeline (CP-14).

Part C - Constraints & Boundaries

C.1 Regulatory Compliance

  • CIMA Reg. 01-24 (digital distribution + management):
  • Mandatory information surfaces rendered by CP-01/02/04/12 are the V1 working set (CP-TV-01); article-anchored validation still pending (CP-OQ-01 / Legal-Compliance audit gap #9).
  • Pre-payment consent (CP-04) provides a probative trail (un-pre-checked checkbox + content-version + audit timestamp) - treated as sufficient digital proof of acceptance for V1 (CP-TV-02 / CP-OQ-02).
  • Contestation procedure is surfaced on the reçu screen (CP-07) and within "Mentions légales" (CP-12) per V1 PRD §16.2.
  • Technical service provider positioning (V1 PRD §10, §16.1) is preserved: CP renders the broker's identity, agrément number, and contestation contact on every screen - never the platform's brand without the broker's.

  • Côte d'Ivoire law n°2013-450 + ARTCI:

  • Minimisation: CP never asks the customer for any data not strictly needed (no CGU acceptance form fields, no profile data, no marketing opt-ins). DOB / RCCM is owned by AUTH and never re-collected by CP.
  • Sub-processor disclosure: NOTIF's cross-border block (NOTIF-US-08) is rendered on the "Mentions légales" page (CP-12). Naming Meta + the BSP + the SMS aggregator transparently.
  • Retention: customer_session rows + payment_attempts are retained per the audit-log retention policy (TBD per AUDIT module + Legal-Compliance audit gap #11). Receipt PDFs retention is owned by PAY.
  • DPIA inputs (CP-OQ-02 lawful basis, CP-OQ-05 mandataire identity): CP exposes the surfaces; resolution is a legal task.

  • Evidence and audit trail (V1 PRD §16.5):

  • The chronology notification → click → auth → list_view → consent → payment_initiated → payment_confirmed → recu_downloaded is reconstructible from the AUDIT module using the events listed in §B.3 plus AUTH's events.
  • Each event carries customer_session_id + transaction_reference (where applicable) + tenant_id + customer_file_id to allow tenant-scoped reconstruction.

  • Payment compliance (V1 PRD §16.4): CP NEVER initiates a payment to a personal mobile-money number - the merchant account is a TENANT-level setting consumed by PAY. CP's role is to display the official broker brand and surface a contestation channel.

C.2 Localization

  • Language: French only in V1 (fr-FR). All UI strings, error messages, audit-event copy, and PDF reçu fields go through i18n (CLAUDE.md rule 3).
  • Currency: XOF in V1 (CI-only). Display: integer minor units rendered with space as thousands separator and zero decimals (15 000 XOF). Stored as integer minor units (CLAUDE.md rule 2). Multi-currency (XAF) is V2+.
  • Date format: JJ/MM/AAAA for input and display.
  • Phone display: +225 XX XX XX XX for CI numbers. Internal storage E.164.
  • Insurance vocabulary: prime, échéance, assuré, souscripteur, quittance, branche (per CLAUDE.md French translation rules).
  • Receipt PDF locale: French header + body; PAY owns the template, CP only references the reçu via signed URL.

C.3 Security

  • Tenant isolation (CLAUDE.md rule 1): every CP query filters by tenant_id resolved from the customer_session row. No URL-derived tenant context is ever trusted. Cross-tenant access on a CP endpoint is structurally impossible.
  • Signed-link scheme: AUTH owns it; CP relies entirely on AUTH's verifier. CP NEVER decodes link contents or attempts re-auth - failures route via AUTH's verifier responses (R-CP-032).
  • CP-side read-only session (post-auto-revoke for CP-13): bound to (link_id, IP, UA fingerprint), 5-min sliding expiry, cookie-less (token in URL fragment refreshed each interaction). Limited to receipt re-download; no mutating actions allowed.
  • Idempotency: every payment-initiation request from CP to PAY carries an idempotency_key; the partial UNIQUE on customer_sessions.inflight_transaction_id (CP-NFR-15) prevents two concurrent initiations on the same session.
  • Anti-replay on return URL: CP MUST verify that the transaction_reference query parameter on the return URL matches the customer session's in-flight attempt (R-CP-016).
  • Encryption: TLS 1.2+ for every customer-facing endpoint; HSTS on the customer-portal domain. Reçu PDFs delivered exclusively via short-lived signed URLs (CP-NFR-07).
  • Audit logging: every event in §B.3 is emitted with the V1 PRD §18 base fields. Audit-log retention follows CP-OQ-03 / AUDIT module rules.
  • Input validation + authz: CP enforces (a) selection set is a subset of currently-eligible contracts for the session; (b) no per-contract amount input; (c) consent boolean; (d) idempotency-key format. Authorization is delegated to AUTH (any 401/403 from AUTH triggers a hard reload - R-CP-032).
  • PII minimisation in the URL: the signed link itself carries no clear-text PII (AUTH-R-CP-001). CP never appends any PII to the URL - selection state lives server-side.
  • Browser cache discipline: CP HTML responses are Cache-Control: no-store (CP-NFR-12) so back/forward never reveals stale logged-in views to a subsequent device user.

C.4 Connectivity (V1 PRD §30)

  • Initial payload < 200 KB gzipped on every screen (CP-NFR-01). React 19.2 + TS bundle is route-split per CP-01 / CP-04 / CP-07 / CP-08 / CP-09 / CP-10 / CP-11 / CP-12 / CP-13. No heavy state library on the critical path. SSR/streaming used where it reduces TTFB on Slow 3G.
  • Resumability (CP-NFR-04): selection state is server-side (R-CP-006), payment state is server-side (R-CP-013), reçu PDF is server-side. A reload at any point within the 72h TTL restores the screen the customer was on. No client-side queue, no service worker, no IndexedDB.
  • Offline indicator (R-CP-030): visible banner within 5 s of detected failure; never a silent failure (V1 PRD §30.2 explicit requirement).
  • Pending-payment reconciliation (R-CP-019..R-CP-021): polling resumes after reload; PAY's idempotency guarantees no double-charge; the customer can leave the page and come back.
  • Slow-3G smoke test (CP-NFR-09 + CLAUDE.md rule 12): every customer-facing story is verified on a throttled Slow 3G profile (RTT 2 s, 400 Kbps down) before declaring done.
  • SMS as backup channel for delivery (V1 PRD §30.2): owned by NOTIF + AUTH; CP renders identical content regardless of channel of click - the signed link variants (?ch=wa | sms | em) only differ in the AUTH log marker.

C.5 Out of Scope (V1)

  • Per-contract partial-amount payment (V2).
  • Installment / échéancier plans (V2).
  • Customer-uploaded documents, claims, attestation requests, contract changes (V2/V3).
  • Broker-uploaded quittance download (V2).
  • Auto-generated provisional attestation d'assurance (V3 - regulatory risk).
  • Refund / dispute initiation by the customer (V2 - current contestation channel is "call the agency").
  • Marketing or promotional surfaces on the portal.
  • Multi-language UI (V2 EN, V3 multi-locale).
  • Multi-currency (V2 XAF).
  • In-portal chat / customer-service handover (V2).
  • Native mobile app (V1 PRD §30.4 explicit non-objective).
  • Service worker / IndexedDB / offline-first engine on the customer side (CLAUDE.md rule 11).
  • WebSocket / SSE push from PAY → CP (V2 - V1 polls).
  • Customer self-service link re-issuance (V1: only operator can re-issue per AUTH-CP-08).
  • Customer-side AUTH OTP step-up (Level 2 in AUTH; V2).
  • Customer-side preference UI for channel of communication (V2 - driven from NOTIF).
  • Embedded PSP checkout (V3).

End of customer-portal PRD V1.