Story CP-002a: Pre-payment summary + consent capture (no PAY call yet)¶
Module: customer-portal
Slice: CP-002 sub-slice (a) (architecture §11 — CP-002 split into a/b/c)
Side: [CUSTOMER] + [BACKEND]
Version target: [V0.0.1]
Priority: 2
Depends on: CP-001
Can develop concurrently with: None (CP-002b builds on the consent + summary)
Merge order: After CP-001, before CP-002b
Estimated complexity: M
PRD User Stories: CP-04
Design screen: CP-S2 (Récapitulatif + Consentement) — see docs/design/customer-portal-design.md §4 (CP-S2), §8, §10 (compliance UI)
Wireframe: docs/wireframes/customer-portal/CP-002/ (prototype pending — design §12 lists CP-S2 as 2nd-priority prototype; run /wireframe)
[LEGAL-REVIEW] BLOQUANT — Magic-link as opposable consent =
INCERTAIN - avocat requis. Additionally, the probative value of the un-pre-checked consent checkbox (CP-OQ-02) isINCERTAIN. Percompliance-discipline.mdthese block production ship until legal clears them. Renouvellement à l'identique only — any change to garanties / montant / durée is out of scope and blocks the story.
Objective¶
Add the pre-payment summary screen and the audited consent capture. When the customer taps "Payer" on the contract list, CP re-checks eligibility, shows a summary of the selected contracts + total, the droit de rétractation notice (CIMA art. 24), and an un-pre-checked consent checkbox. Ticking it records customer_consent_captured bound to the session with the mentions-légales content version. No PAY call yet — the "Payer (final)" action is mocked/stubbed in this slice (real initiation lands in CP-002b).
Backend Scope¶
Entities¶
Uses customer_sessions (consent_content_version, consent_ticked_at, state) + mentions_legales_versions (read content version). No new tables.
Migrations¶
None (schema frozen in CP-001).
Service Layer¶
SummaryService.recap(tenantId, session)— re-queries the eligible set (R-CP-001) and intersects with the persisted selection (R-CP-009). If the intersection is empty → redirect signal to the contract list with copy. If the intersection differs from the persisted selection → return the summary with a "selection changed" flag requiring explicit confirm before consent unlocks. Emitscustomer_summary_viewed.ConsentService.capture(tenantId, session, contentVersion)— setsconsent_content_version(read at tick time per CP-OQ-07 default) +consent_ticked_aton the session; transitions state SUMMARY → CONSENTED. Emitscustomer_consent_captured. Consent is never pre-ticked and never auto-ticks on reload (R-CP-010).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| GET | /api/portail/session/recap |
— | 200 {contracts[], total_amount_minor, currency, content_version, selection_changed?} |
cookie |
| POST | /api/portail/session/consent |
{content_version} |
204 (consent bound to session) | cookie |
X-API-Version: 1via apiClient.ts. JSONprivate, must-revalidate+ ETag.
Validation Rules¶
- Recap re-checks eligibility server-side; only currently-eligible items returned.
- Consent body
content_versionmust equal the latest published version for the tenant; mismatch → re-render summary with the current version. - Total is the sum of selected
contract.prime(XOF integer minor units); no per-contract amount input (R-CP-008).
Multi-Tenant Considerations¶
tenant_id from the cookie-bound session only. mentions_legales_versions read scoped by tenant_id. Content version is tenant-specific.
Audit & Logging¶
customer_summary_viewed— on GET /recap (customer_session_id,contract_ids[],total_amount_minor,currency).customer_consent_captured— on POST /consent (customer_session_id,content_version,contract_ids[],total_amount_minor,currency,ticked_at).
Frontend Scope¶
Pages & Routes¶
/portail/session/recap→SummaryConsentPage(route-split, lazy, loaded on "Payer" intent; chunk ~8 KB per arch §8.1).
Components¶
SummaryConsentPage, per-contractContractSummaryRow(read-only Card), Total bar,Checkboxconsent (un-pre-checked, design §5 consent rules), rétractation block,BrokerHeader(sticky),StepIndicator(step 2 active, design D1), mentions-légales footer link, primary "Payer" Button (disabled until consent ticked),NetworkBanner.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | ContractSummaryRow skeletons + Total skeleton; consent block hidden until data loaded | — |
| Empty | If intersection empty after re-check → Toast error + redirect to list | « Aucun contrat sélectionné n'est plus éligible. » |
| Error | XHR eligibility re-check fails → InlineBanner error + Button ghost « Réessayer »; BrokerHeader still rendered | « Impossible de charger le récapitulatif. Réessayez. » |
| Offline / low network | NetworkBanner; consent tick is optimistic-local; customer_consent_captured POST retries on reconnect; Pay button enabled on local tick |
« Connexion instable. Vos choix sont enregistrés. » |
| Success | Contracts read-only; checkbox un-ticked; Pay button disabled; on tick → POST audit event, Pay button enabled | h1 « Récapitulatif » · sub « Vérifiez vos contrats avant de payer. » · Total « Total : {total} XOF » · Consent « Je confirme avoir pris connaissance des conditions des contrats sélectionnés et des mentions légales du cabinet. » · Pay « Payer {total} XOF par mobile money » · Back « ← Modifier ma sélection » |
Droit de rétractation block (CIMA art. 24 — MUST appear between the consent checkbox and the Pay button, design §10 placement rule 3): « Vous disposez d'un délai de 14 jours pour exercer votre droit de rétractation. Contactez votre cabinet au {agency.phone}. » Selection-changed notice (R-CP-009): « Votre sélection a changé : voici le détail à jour. » (InlineBanner warning above contracts; consent checkbox hidden + Pay button disabled until the user taps « Confirmer »).
Responsive Behavior¶
Mobile-first 360 px; ≥ 44 px touch targets; single-column stack at 360; 768 = centered 600px column; 1024–1440 = centered 500px column (design §6). Currency 15 000 XOF, date JJ/MM/AAAA.
Connectivity Behavior (CUSTOMER stories only)¶
- Summary chunk lazy-loaded only on "Payer" intent (keeps landing path light).
- Survives reload: consent state lives on the session; a reload re-reads it. But consent must be freshly ticked for every fresh payment attempt — it never auto-ticks on reload (R-CP-010).
- Survives disconnect: consent POST retried with backoff; summary re-fetched on screen re-entry (CP-NFR-12).
Interactions¶
- "Payer" on the list navigates to /recap (lazy chunk).
- Consent checkbox toggles the primary button enabled/disabled; state CONSENTED ⇄ SUMMARY on tick/untick.
- In this slice, the final "Payer" action is stubbed (mock) — wired to PAY in CP-002b.
Acceptance Criteria¶
AC-002a.1: Summary renders the selected set with total, rétractation notice and an un-pre-checked consent box
Given I tapped "Payer" on the contract list with a non-empty selection
When the summary screen renders (GET /recap)
Then I see h1 « Récapitulatif », one read-only row per selected contract (branche, n° police, période, prime), the aggregated « Total : {total} XOF », the BrokerHeader, the StepIndicator on step 2, the droit-de-rétractation notice « Vous disposez d'un délai de 14 jours… » BETWEEN the consent checkbox and the Pay button, the mentions-légales footer link, and a single un-pre-checked consent checkbox; the primary action is disabled until it is ticked; customer_summary_viewed is emitted
AC-002a.2: Eligibility re-checked on summary entry
Given the eligible set changed between the list and the summary
When the summary opens
Then the server re-queries eligibility; if the intersection is empty it redirects to the list with « Aucun contrat sélectionné n'est plus éligible. »; if it differs it shows « Votre sélection a changé : voici le détail à jour. » and requires explicit « Confirmer » before the consent box unlocks
AC-002a.3: Consent capture is audited with content version
Given the summary screen
When I tick the consent checkbox (POST /consent)
Then consent_content_version (read at tick time) + consent_ticked_at are bound to my session, the state becomes CONSENTED, the "Payer" button enables, and customer_consent_captured is emitted with content_version, contract_ids[], total_amount_minor, currency, ticked_at; the box is never pre-ticked and never auto-ticks on reload
Compliance Rules¶
- Magic-link opposable consent + probative value of checkbox consent (CP-OQ-02) =
INCERTAIN - avocat requis([LEGAL-REVIEW], BLOQUANT) — see header. - CIMA Reg. 01-24 mandatory pre-contractual information (
VÉRIFIÉper PRD: arts. 22-23 + Loi 2013-546 art. 19): mentions précontractuelles surfaced before payment. - Droit de rétractation 14 j affiché AVANT paiement (
VÉRIFIÉ, CIMA art. 24): the rétractation block MUST render between the consent checkbox and the Pay button (design §10 placement rule 3) — never after. - Double-clic obligatoire (
VÉRIFIÉ, Loi 2013-546 art. 20): consent tick (action 1) + a subsequent explicit "Payer" action (action 2) constitute the double action (initiation completed in CP-002b). - Consent content-version capture is the basis for the audit trail (CP-TV-02).
Standards & Conventions¶
docs/standards/critical-rules.md— money (XOF minor units), audit on action, tenant isolation.docs/standards/api-guidelines.md— envelope, ETag, header versioning.docs/standards/connectivity-low-bandwidth.md— lazy chunk on intent, server-side state.docs/standards/react-typescript-guidelines.md— TanStack Query mutation, apiClient.ts.docs/standards/design-system.md+design-principles.md— checkbox, button states, no silent failure.docs/design/customer-portal-design.md— §4 CP-S2 (5 states + rétractation placement), §8 micro-copy, §10 compliance UI.docs/standards/compliance-discipline.md—[LEGAL-REVIEW]markers.
Testing Requirements¶
Unit Tests¶
- Recap intersection logic: empty → redirect; differs → selection-changed flag; equal → normal.
- Consent capture sets version + timestamp; reload does not auto-tick.
Integration Tests¶
- GET /recap returns only currently-eligible items + emits customer_summary_viewed.
- POST /consent binds content_version + emits customer_consent_captured.
- Tenant isolation on recap + consent (tenant B cannot reach tenant A's session).
What QA Will Validate¶
- Renders at 360 / 768 / 1024 / 1440; all 5 UI states; French copy exact; rétractation block sits between consent and Pay; Slow-3G smoke; consent never pre-ticked, re-tick required after reload.
Out of Scope¶
- Real PAY initiation, redirect, idempotency, PaymentConfirmed listener (CP-002b).
- Receipt screen + downloads (CP-002c).
- Status polling / pending screen (CP-003).
- Full mentions-légales page render (CP-S9, CP-006 / V1) — only the footer link + content-version read are in scope here.
Definition of Done¶
- [ ] GET /recap + POST /consent implemented
- [ ] Summary + consent screen renders at 360 / 768 / 1024 px (500px column at 1440)
- [ ] All 5 UI states implemented (loading, empty, error, offline/low-network, success)
- [ ] French micro-copy matches design §8 exactly (h1, Total, consent, rétractation, Pay, Back)
- [ ] Droit-de-rétractation block renders between consent checkbox and Pay button
- [ ] StepIndicator step 2 + BrokerHeader present
- [ ] AC-002a.1 … AC-002a.3 pass manually
- [ ] customer_summary_viewed + customer_consent_captured emitted with required fields
- [ ] Consent never pre-ticked; re-tick required for every fresh attempt
- [ ] Tenant isolation verified (both SHARED + BYO profiles)
- [ ] Summary chunk lazy-loaded only on "Payer" intent
- [ ] Flow tested under throttled "Slow 3G"
- [ ] No silent failures on network errors