Aller au contenu

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 a payment_attempts row (status CREATED), call PAY.PaymentClient.initiate({tenant_id, customer_file_id, customer_session_id, contract_ids[], amount_total_minor, currency=XOF, idempotency_key, return_url, cancel_url}), set inflight_transaction_id + transaction_reference + provider, transition CREATED → INITIATED, and 303-redirect to PAY's redirect_url. Propagates correlation_id to 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) — verifies refParam == session.inflight transaction_reference (R-CP-016); mismatch → generic error + customer_payment_return_mismatch. Then PAY.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 — on PaymentConfirmed/PaymentFailed/PaymentExpiredPending updates the local payment_attempts.status projection (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: 1 via apiClient.ts.

Validation Rules

  • Idempotency-Key header required (UUIDv4 format); reject a second initiation while a non-terminal attempt exists (R-CP-013, CP-NFR-15).
  • amount_total_minor = sum of selected contract.prime, XOF integer minor units; currency=XOF.
  • Return-URL transaction_reference MUST 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/return handling — 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

  • PaymentRedirect with the CP-S3 loading treatment, minimal confirmed/pending/failed placeholders, BrokerHeader on 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