Story CP-003: Pending screen + status polling + manual retry + per-contract toggle + phone-last-4¶
Module: customer-portal
Slice: CP-003 (architecture §11)
Side: [CUSTOMER] + [BACKEND]
Version target: [V0.0.2]
Priority: 5
Depends on: CP-002c (CP-002 fully merged)
Can develop concurrently with: CP-005 (after CP-002 merged)
Merge order: After CP-002c
Estimated complexity: M
PRD User Stories: CP-08, CP-09, CP-03 (per-contract toggle part)
Design screens: CP-S5 (Vérification en cours) + CP-S6 (Échec du paiement) — see docs/design/customer-portal-design.md §4 (CP-S5, CP-S6), §8, decisions D6 + D10
Wireframe: docs/wireframes/customer-portal/CP-003/ (prototype pending — design §12; run /wireframe)
[LEGAL-REVIEW] BLOQUANT — Magic-link opposable consent =
INCERTAIN - avocat requis. Blocks production ship. Renouvellement à l'identique only.
Objective¶
Add the "vérification en cours" pending screen (CP-S5) with TanStack Query polling, the failed-payment screen (CP-S6) with French mapped reasons + manual retry, the per-contract toggle (replaces v0.0.0 all-or-none), and phone-last-4 capture at payment time. This is the v0.0.2 slice that makes payment robust under unknown/slow PSP returns.
Backend Scope¶
Entities¶
Uses payment_attempts (status PENDING/FAILED/EXPIRED_PENDING, attempt_index, phone_last4 nullable column — already present in the frozen schema from CP-001 per arch §9.3). No new tables.
Migrations¶
None (schema frozen). phone_last4 populated from PAY response at attempt creation only; historical rows stay NULL.
Service Layer¶
PaymentStatusService.status(tenantId, session, ref)— single indexed read onpayment_attempts (transaction_reference)+ strong ETag (304 if unchanged). Returns{status, mapped_reason?, polled_at}. Emitscustomer_payment_status_polled(sampled ≤ 1/5 s + final transition).PaymentEventListener(extended) — handlesPaymentFailed→ FAILED,PaymentExpiredPending→ EXPIRED_PENDING in the projection; emitscustomer_payment_failed/customer_payment_expired_pending.- Mapped-reason table (R-CP-025, design §4 CP-S6): provider error code → French copy. Codes:
SOLDE_INSUFFISANT,CODE_REFUSÉ,ANNULATION_CLIENT,INDISPONIBLE,EXPIRATION,RETOUR_INCOHÉRENT,AUTRE,PAY_INITIATION_FAILED,EXPIRED_PENDING; unknown →AUTRE.RETOUR_INCOHÉRENTis a security signal — the failed screen hides the Réessayer CTA and shows only the contact CTA (design D10). - Retry (R-CP-026): marks the failed attempt terminal, returns the customer to the summary with the persisted selection intact, requires a fresh consent tick, generates a new idempotency_key on the next "Payer". The previous failed tx is never reused.
attempt_indexincrements (informational; no cap in V1 per CP-TV-03). - Phone-last-4: capture
customer_type+ phone last-4 from the PAY response at attempt creation (not full identity verification).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| GET | /api/portail/session/payments/{ref}/status |
— | 200 {status, mapped_reason?, polled_at} + strong ETag (304 if unchanged) |
cookie |
X-API-Version: 1.refetchIntervalInBackground=false(tab-hidden pauses polling).
Validation Rules¶
- Status read scoped to the session's tenant + in-flight ref.
- Polling cadence: every 5 s for the first 60 s, then every 30 s, no upper bound while PENDING (CP-NFR-06 / R-CP-019).
Multi-Tenant Considerations¶
tenant_id from the cookie-bound session. payment_attempts status read carries tenant_id. Phone-last-4 is a non-PII snapshot (last 4 digits only).
Audit & Logging¶
customer_payment_status_polled— each poll, sampled (transaction_reference,poll_index,status_returned).customer_payment_failed— on FAILED applied (transaction_reference,provider_error_code,mapped_reason).customer_payment_expired_pending— on EXPIRED_PENDING applied (transaction_reference).
Frontend Scope¶
Pages & Routes¶
PendingPaymentPage(CP-S5, lazy chunk ~6 KB per arch §8.1) +PaymentFailedPage(CP-S6). Per-contract toggle added to the existingContractListPage.
Components¶
PendingPaymentPage(CSS-only pulsing ring per design D6 —prefers-reduced-motion→ static ring + attempt counter; reference + amount + provider card; TanStack QueryrefetchInterval),PaymentFailedPage(neutral amber icon per D10, mapped reason + CTAs), per-contract checkbox/toggle on the list.BrokerHeaderon every screen (CP-NFR-13); StepIndicator hidden on these terminal/edge screens (design §3.2).NetworkBanner.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Brief BrokerHeader skeleton then the pending state; contract list locked read-only behind it | « Paiement en cours de vérification… » |
| Empty | N/A | — |
| Error | Failed screen (CP-S6) with mapped French reason + CTAs | « Échec du paiement » + mapped reason (e.g. « Solde insuffisant sur votre compte mobile money. ») |
| Offline / low network | NetworkBanner; polling pauses; on reconnect a Toast then polling resumes | Banner « Connexion instable. Vos choix sont enregistrés. » · Toast « Connexion rétablie. Vérification en cours… » |
| Success | Poll → CONFIRMED routes to the receipt (CP-002c) | (handled by CP-002c) |
Pending 0–60 s body: « Cela peut prendre jusqu'à quelques minutes. Ne fermez pas cette page si possible. »
Pending after 60 s: body « Cela prend plus de temps que prévu. Vous recevrez un message dès que le paiement est confirmé. » + exit « Vous pouvez fermer cette page. »
Poll-error banner (3 consecutive poll failures): « Vérification difficile. Votre paiement est en cours de traitement. » (polling continues).
Expired-pending: « Le paiement n'a pas pu être confirmé. Aucun montant n'a été prélevé. Vous pouvez réessayer. »
Failed CTAs: « Réessayer le paiement » + « 📞 Contacter le cabinet ». Status-unknown: « Impossible de vérifier l'état du paiement. Contactez le cabinet. » (contact CTA only).
Mapped reasons (R-CP-025, design §4 CP-S6 table):
- SOLDE_INSUFFISANT → « Solde insuffisant sur votre compte mobile money. »
- CODE_REFUSÉ → « Le code saisi a été refusé par votre opérateur. »
- ANNULATION_CLIENT → « Vous avez annulé le paiement. »
- INDISPONIBLE → « Le service de paiement est temporairement indisponible. Réessayez. »
- EXPIRATION → « Le délai de paiement est dépassé. Réessayez. »
- RETOUR_INCOHÉRENT → « Une incohérence a été détectée. Contactez le cabinet. » (Réessayer hidden — contact CTA only)
- AUTRE → « Le paiement n'a pas abouti. Réessayez ou contactez le cabinet. »
- PAY_INITIATION_FAILED → « Impossible de démarrer le paiement. Réessayez ou contactez le cabinet. »
- EXPIRED_PENDING → « Le paiement n'a pas pu être confirmé. Aucun montant n'a été prélevé. Vous pouvez réessayer. »
Responsive Behavior¶
Mobile-first 360 px; ≥ 44 px targets; 768 = centered 600px column; 1024–1440 = centered 500px column (design §6). Pending + failed screens single-column; per-contract toggles full-width tappable rows.
Connectivity Behavior (CUSTOMER stories only)¶
- Polling stops on tab close, resumes on reload (R-CP-020).
refetchIntervalInBackground=false. - Survives reload / return days later within the link window: still-PENDING tx re-enters the pending screen and polling resumes (R-CP-021).
- TanStack Query (~13 KB gzip) gates polling; fits the 180 KB landing budget.
Interactions¶
- Per-contract toggle: tap a row to select/deselect individually (debounced persist as in CP-001).
- Réessayer on failed: returns to summary with selection intact, requires fresh consent, new idempotency_key.
Acceptance Criteria¶
AC-003.1: Pending screen polls at the required cadence
Given PAY status is PENDING
When the pending screen mounts
Then I see « Paiement en cours de vérification… » with a CSS pulsing ring, reference, amount and provider, the body « Cela peut prendre jusqu'à quelques minutes. Ne fermez pas cette page si possible. », the contract list locked read-only, and CP polls GET /payments/{ref}/status every 5 s for 60 s then every 30 s with no upper bound; after 60 s the copy updates to « Cela prend plus de temps que prévu… » + « Vous pouvez fermer cette page. »; each poll emits customer_payment_status_polled (sampled)
AC-003.2: Poll transitions route to the right screen
Given the pending screen is polling
When the status flips
Then CONFIRMED routes to the receipt screen, FAILED routes to the failed screen, and EXPIRED_PENDING shows « Le paiement n'a pas pu être confirmé. Aucun montant n'a été prélevé. Vous pouvez réessayer. »; polling stops on any terminal state and resumes on reload while still PENDING; a network drop pauses polling and on reconnect shows the Toast « Connexion rétablie. Vérification en cours… » before resuming
AC-003.3: Failed payment shows a mapped French reason and preserves selection on retry
Given PAY status is FAILED
When the failed screen renders
Then I see « Échec du paiement » + the French reason mapped from the provider error code + « Réessayer le paiement » + « 📞 Contacter le cabinet », and customer_payment_failed is emitted; for RETOUR_INCOHÉRENT the Réessayer CTA is hidden (contact only); tapping Réessayer returns me to the summary with my selection intact, requires a fresh consent tick, and generates a new idempotency_key (the previous failed tx is never reused); attempt_index increments (no platform cap)
AC-003.4: Per-contract toggle and phone-last-4 capture
Given the v0.0.2 contract list
When I select contracts
Then I can toggle each contract individually (replacing all-or-none); and when a payment attempt is created, the customer's phone last-4 (and customer_type) is captured from the PAY response and stored on the attempt (not full identity verification)
Compliance Rules¶
- Magic-link opposable consent =
INCERTAIN - avocat requis([LEGAL-REVIEW], BLOQUANT). - No double-charge (CP-TV-04): the pending screen locks new initiations; PAY's
EXPIRED_PENDINGis the sole stop signal; PAY idempotency guarantees no double-charge. - Data minimisation (Loi 2013-450,
VÉRIFIÉ): only phone last-4 captured, never the full number, never full identity verification in V0. - Retry uncapped in V1 (CP-TV-03): attempt_index is informational only.
RETOUR_INCOHÉRENTretry suppression (design D10): an incoherent return is a tampering/security signal — retry must not be suggested.
Standards & Conventions¶
docs/standards/critical-rules.md— money, tenant isolation, audit, no silent failure.docs/standards/connectivity-low-bandwidth.md— polling cadence, tab-hidden pause, resumability.docs/standards/api-guidelines.md— ETag/304, header versioning.docs/standards/react-typescript-guidelines.md— TanStack Query refetchInterval, apiClient.ts.docs/standards/design-system.md+design-principles.md— spinner/pulsing ring, banners, toggles.docs/design/customer-portal-design.md— §4 CP-S5 + CP-S6 (5 states, copy, error-code mapping), §8, decisions D6/D10.docs/standards/compliance-discipline.md—[LEGAL-REVIEW]markers.
Testing Requirements¶
Unit Tests¶
- Mapped-reason table (each code + unknown → AUTRE; RETOUR_INCOHÉRENT hides retry).
- Poll cadence transition (5 s → 30 s after 60 s).
- Retry: failed terminal, selection preserved, fresh consent required, new idempotency_key.
Integration Tests¶
- GET /payments/{ref}/status returns ETag, 304 when unchanged.
- PaymentFailed / PaymentExpiredPending events flip projection + emit audit.
- Resume after disconnect returns to pending screen, then CONFIRMED (arch §13.3).
- Tenant isolation on status read.
What QA Will Validate¶
- Renders at 360 / 768 / 1024 / 1440; all 5 UI states; French copy exact (pending 0–60 s + >60 s, poll-error banner, reconnect Toast, expired, all mapped reasons); RETOUR_INCOHÉRENT hides retry; Slow-3G smoke; polling pauses on tab hide; per-contract toggle works.
Out of Scope¶
- Lazy-load bundle-budget enforcement gate (CP-004).
- Link TTL / 72 h expiry / rate limiting (CP-005).
- "Lien remplacé" (CP-S7), "déjà réglé" re-download (CP-S8), mentions-légales page (CP-S9) — CP-006 / V1.
Definition of Done¶
- [ ] GET /payments/{ref}/status implemented (ETag, 304, sampled audit)
- [ ] Pending (CP-S5) + failed (CP-S6) screens render at 360 / 768 / 1024 px (500px column at 1440)
- [ ] Per-contract toggle replaces all-or-none; phone-last-4 captured at payment
- [ ] All 5 UI states implemented (loading, empty, error, offline/low-network, success)
- [ ] French micro-copy matches design §8 exactly (pending 0–60/>60, poll-error, reconnect Toast, expired, all 9 mapped reasons)
- [ ] RETOUR_INCOHÉRENT hides the Réessayer CTA (contact only)
- [ ] AC-003.1 … AC-003.4 pass manually
- [ ] Audit: payment_status_polled (sampled), payment_failed, payment_expired_pending
- [ ] Tenant isolation verified (both SHARED + BYO profiles)
- [ ] Polling pauses on tab hide, resumes on reload while PENDING
- [ ] Flow tested under throttled "Slow 3G"
- [ ] No silent failures on network errors