Story CP-002b: Payment initiation + PSP redirect + return/status resolution¶
Module: customer-portal
Slice: CP-002 sub-slice (b) (architecture §11)
Side: [CUSTOMER] + [BACKEND]
Version target: [V0.0.1]
Priority: 3
Depends on: CP-002a; PAY initiate/status API live (sandbox); PAY emits PaymentConfirmed/PaymentFailed/PaymentExpiredPending
Can develop concurrently with: None (CP-002c builds on a confirmed payment)
Merge order: After CP-002a, before CP-002c
Estimated complexity: M
PRD User Stories: CP-05, CP-06
Design screen: CP-S3 (Initiation paiement — redirect loading) — see docs/design/customer-portal-design.md §4 (CP-S3), §8; failed surface = CP-S6 mapping
Wireframe: docs/wireframes/customer-portal/CP-002/ (prototype pending — design §12; run /wireframe)
[LEGAL-REVIEW] BLOQUANT — Magic-link opposable consent + checkbox probative value =
INCERTAIN - avocat requis. Blocks production ship. Renouvellement à l'identique only.
Objective¶
Wire the real payment initiation: on the final "Payer", CP calls PAY with an idempotency key, records the in-flight transaction on the session, and 303-redirects to the PSP hosted page. On return, CP reads the transaction reference, verifies it against the session's in-flight attempt, queries PAY status once, and routes to the right screen (confirmed / pending / failed). A PaymentConfirmed/PaymentFailed/PaymentExpiredPending listener mirrors the PAY status into the local payment_attempts projection. (Continuous polling + the dedicated pending screen come in CP-003; here the return does a single status read and routes.)
Backend Scope¶
Entities¶
Uses payment_attempts (created frozen in CP-001) + customer_sessions.inflight_transaction_id. No new tables.
Migrations¶
None.
Service Layer¶
PaymentInitiationService.initiate(tenantId, session, idempotencyKey)— guards on the partial UNIQUE (inflight_transaction_id IS NOT NULL): if a non-terminal attempt exists, do NOT start a second transaction — return the existing one (route to status). Otherwise insert apayment_attemptsrow (status CREATED), callPAY.PaymentClient.initiate({tenant_id, customer_file_id, customer_session_id, contract_ids[], amount_total_minor, currency=XOF, idempotency_key, return_url, cancel_url}), setinflight_transaction_id+transaction_reference+provider, transition CREATED → INITIATED, and 303-redirect to PAY'sredirect_url. Propagatescorrelation_idto PAY.- Failure handling (R-CP-014): PAY error within 10 s → render the CP-S6 failed surface (
provider_error_code=PAY_INITIATION_FAILED, mapped copy « Impossible de démarrer le paiement. Réessayez ou contactez le cabinet. »). No response within 15 s (CP-NFR-14) → close the attempt terminal (INITIATION_FAILED,outcome=INITIATION_TIMEOUT), assume no upstream tx, show CP-S6. ReturnResolutionService.resolve(tenantId, session, refParam)— verifiesrefParam == session.inflight transaction_reference(R-CP-016); mismatch → generic error +customer_payment_return_mismatch. ThenPAY.getStatus(ref)once → routes CONFIRMED / PENDING / FAILED / EXPIRED_PENDING / CANCELLED_BY_USER. Missing ref → route by the session's in-flight status (R-CP-018), never a 4xx.PaymentEventListener— onPaymentConfirmed/PaymentFailed/PaymentExpiredPendingupdates the localpayment_attempts.statusprojection (mirror), idempotently. Race with return-URL is harmless (return reads the projection).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/portail/session/payments |
header Idempotency-Key: <uuid4> |
303 → PSP redirect_url (or routed failure) |
cookie + Idempotency-Key |
| GET | /api/portail/session/return |
?ref=<transaction_reference> (optional) |
routed CP screen state ({screen: CONFIRMED\|PENDING\|FAILED}) |
cookie |
- POST /payments must be a 303 See Other to the PSP (never an
<a href>) to avoid back-nav cache replay (R-CP-015). X-API-Version: 1via apiClient.ts.
Validation Rules¶
Idempotency-Keyheader required (UUIDv4 format); reject a second initiation while a non-terminal attempt exists (R-CP-013, CP-NFR-15).amount_total_minor= sum of selectedcontract.prime, XOF integer minor units;currency=XOF.- Return-URL
transaction_referenceMUST match the session's in-flight reference (R-CP-016).
Multi-Tenant Considerations¶
tenant_id, agency_id, customer_file_id frozen on the attempt row from the cookie-bound session. PAY provider list resolved per country_code (V1 = XOF only). Every payment_attempts query scoped by tenant_id.
Audit & Logging¶
customer_payment_initiated— POST /payments → 303 (transaction_reference,idempotency_key,provider,contract_ids[],amount_total_minor,currency).customer_payment_initiation_failed— PAY 5xx/timeout (idempotency_key,upstream_reason_code).customer_payment_return_mismatch— return ref ≠ session ref (expected_reference,received_reference,ip,user_agent).customer_payment_confirmed— on PaymentConfirmed applied (transaction_reference,contract_ids[],amount_total_minor,currency).customer_payment_failed— on PaymentFailed applied (transaction_reference,provider_error_code,mapped_reason).customer_payment_expired_pending— on PaymentExpiredPending applied (transaction_reference).
Frontend Scope¶
Pages & Routes¶
PaymentRedirect.tsx— issues POST /payments and follows the 303 to the PSP. Renders the CP-S3 initiation loading state while the POST is in flight./portail/session/returnhandling — reads the routed screen state and navigates to a minimal confirmed / pending / failed view. (Rich receipt screen = CP-002c; rich pending + polling = CP-003. Here a basic routed view is enough.)
Components¶
PaymentRedirectwith the CP-S3 loading treatment, minimal confirmed/pending/failed placeholders,BrokerHeaderon every state (CP-NFR-13),StepIndicator(step 3 active on CP-S3, design D1),NetworkBanner.
CP-S3 initiation loading treatment (design §4 CP-S3)¶
- 0–1.5 s: primary Button in loading state + disabled (« Connexion au service de paiement… »); the « ← Modifier ma sélection » button is disabled. Prevents double-tap anxiety on Slow 3G.
-
1.5 s: full overlay with a CSS-only pulsing ring (
prefers-reduced-motion→ static ring), heading « Connexion au service de paiement… », body « Veuillez patienter. », and a provisional reference card (« Référence provisoire PAY-2026-XXXXX » + « Montant {total} XOF »). - Success: 303 redirect to the PSP (browser follows). Error/timeout: transition to the CP-S6 failed surface.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | "Payer" tapped → button loading (0–1.5 s) → pulsing-ring overlay (>1.5 s) with provisional reference card | « Connexion au service de paiement… » · « Veuillez patienter. » |
| Empty | N/A (always a selected set + consent) | — |
| Error | PAY init fail/timeout → CP-S6 failed surface + Réessayer + click-to-call | « Impossible de démarrer le paiement. Réessayez ou contactez le cabinet. » |
| Offline / low network | NetworkBanner; POST to PAY continues server-side; after 15 s server timeout → CP-S6 | « Connexion instable. Vos choix sont enregistrés. » |
| Success | PAY returns redirect_url → 303 to PSP; on return CONFIRMED → minimal confirmed view (full receipt in CP-002c) | « Paiement reçu — référence XXXXXX » (minimal placeholder) |
Return mismatch (tampering, R-CP-016): generic error « Une incohérence a été détectée. Contactez le cabinet. »
Responsive Behavior¶
Mobile-first 360 px; ≥ 44 px targets; 768 = centered 600px column; 1024–1440 = centered 500px column (design §6). Confirmed/pending/failed placeholders single-column.
Connectivity Behavior (CUSTOMER stories only)¶
- Client generates the
Idempotency-Key(UUIDv4) on the "Payer" click; a duplicate click while in-flight does NOT start a second tx (server returns the existing one). - Survives disconnect: if the customer loses the PSP redirect (tab closed, network drop at return), re-opening the same link routes to the screen matching the current PAY status — selection is not restarted (R-CP-004 / CP-06.2).
- Payment resumability: the in-flight transaction lives on the session; a reload reads it and routes.
Interactions¶
- "Payer" (final) → POST /payments → 303 to PSP.
- Return URL → single status read → route.
Acceptance Criteria¶
AC-002b.1: Payment initiation redirects to the PSP with idempotency
Given consent is captured and the selection is fresh
When I tap "Payer" (POST /payments with an Idempotency-Key)
Then CP shows the CP-S3 loading treatment (button loading → pulsing-ring overlay after 1.5 s with a provisional reference card), records an in-flight payment_attempts row, calls PAY, sets inflight_transaction_id + transaction_reference + provider, 303-redirects to the PSP redirect_url, and emits customer_payment_initiated; a duplicate click while in-flight does not start a second transaction
AC-002b.2: Initiation failure routes to a failed surface
Given PAY is unreachable or returns 5xx/timeout
When initiation cannot complete (error < 10 s, or no response within 15 s)
Then the attempt is closed terminally, the customer sees the CP-S6 surface « Impossible de démarrer le paiement. Réessayez ou contactez le cabinet. » with a Réessayer button + click-to-call, and customer_payment_initiation_failed is emitted with the upstream reason
AC-002b.3: Return URL verifies the reference and routes by status
Given the PSP redirects me back to /return?ref=...
When the page loads
Then CP verifies ref matches the session's in-flight reference (mismatch → generic error « Une incohérence a été détectée. Contactez le cabinet. » + customer_payment_return_mismatch), reads PAY status once, and routes CONFIRMED/PENDING/FAILED (EXPIRED_PENDING → failed/EXPIRATION, CANCELLED_BY_USER → failed/ANNULATION_CLIENT); a missing ref routes by the session's in-flight status, never a 4xx
AC-002b.4: Resumable in-flight payment after disconnect
Given I lost the PSP redirect (tab closed / network drop)
When I re-open the same signed link
Then CP detects the in-flight transaction on my session and routes me directly to the screen matching the current PAY status without restarting selection; the PaymentConfirmed/Failed/ExpiredPending listener has mirrored the status into the local projection
Compliance Rules¶
- Magic-link opposable consent + checkbox probative value =
INCERTAIN - avocat requis([LEGAL-REVIEW], BLOQUANT). - Payment compliance (V1 PRD §16.4,
VÉRIFIÉ): CP never initiates payment to a personal mobile-money number — the merchant account is a TENANT setting consumed by PAY. CP only displays the broker brand + contestation channel. - Anti-replay on return URL (R-CP-016): mismatch handled as tampering, audited.
- Idempotency (CP-NFR-15): partial UNIQUE prevents two concurrent initiations; key forwarded to PAY for upstream dedupe.
Standards & Conventions¶
docs/standards/critical-rules.md— money, tenant isolation, audit, no silent failure.docs/standards/api-guidelines.md— 303 redirect, RFC 7807 errors, header versioning.docs/standards/java-spring-guidelines.md— Spring named iface to PAY, event listener, outbox.docs/standards/connectivity-low-bandwidth.md— server-side in-flight state, resumability.docs/standards/react-typescript-guidelines.md— apiClient.ts, idempotency-key generation.docs/design/customer-portal-design.md— §4 CP-S3 (loading treatment), §4 CP-S6 (failure mapping), §8 micro-copy.docs/standards/compliance-discipline.md—[LEGAL-REVIEW]markers.
Testing Requirements¶
Unit Tests¶
- Idempotency gate: second initiation while non-terminal → returns existing tx, no new PAY call.
- Initiation timeout (15 s) → INITIATION_FAILED terminal.
- Return ref mismatch → mismatch event + generic error.
- Status routing table (CONFIRMED/PENDING/FAILED/EXPIRED_PENDING/CANCELLED_BY_USER).
Integration Tests¶
- POST /payments → 303 to PSP (WireMock PAY) + customer_payment_initiated.
- PaymentConfirmed event → projection flips to CONFIRMED; return reads projection (race-safe).
- Resumability: re-open link with in-flight PENDING → routes to pending state (arch §13.3).
- Tenant isolation on payment_attempts.
What QA Will Validate¶
- Renders at 360 / 768 / 1024 / 1440; all 5 UI states; French copy exact; CP-S3 button→overlay timing; Slow-3G smoke; double-click does not double-charge; reload after disconnect routes correctly.
Out of Scope¶
- Continuous status polling + the rich "vérification en cours" pending screen (CP-003).
- Phone-last-4 capture + per-contract toggle (CP-003 / v0.0.2).
- Rich receipt screen + reçu downloads + NOTIF receipt push (CP-002c).
- Link TTL / attempt counter / rate limiting (CP-005).
Definition of Done¶
- [ ] POST /payments (303 to PSP, idempotency-gated) + GET /return implemented
- [ ] CP-S3 loading treatment (button loading → pulsing-ring overlay >1.5 s + provisional reference card) implemented
- [ ] PaymentConfirmed/Failed/ExpiredPending listener mirrors the projection
- [ ] All frontend states render 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
- [ ] StepIndicator step 3 + BrokerHeader present
- [ ] AC-002b.1 … AC-002b.4 pass manually
- [ ] Audit entries: payment_initiated, payment_initiation_failed, payment_return_mismatch, payment_confirmed, payment_failed, payment_expired_pending
- [ ] Tenant isolation verified (both SHARED + BYO profiles)
- [ ] Resumability verified (close + reopen link mid-flow routes by status)
- [ ] Double initiation blocked by partial UNIQUE
- [ ] Flow tested under throttled "Slow 3G"
- [ ] No silent failures on network errors