Aller au contenu

Payment (PAY) - PRD + FSD V1

Module prefix: PAY Plane: Application V1 status: in-scope (V1 PRD §11.6, §16.4, §30.5) Country scope V1: CI only (multi-country resolution path implemented from day one via tenant country_code + country-profile lookup) Owners: ANALYST (this PRD), ARCHITECT (next), PRODUCT_OWNER (story decomposition) Drafted: 01/05/2026


V0 Envelope Scope (ajout 2026-05-21)

Lire docs/prd/prd-v0.md § 4 (ligne PAY) 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.

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

Sous-version Périmètre vs V1 PRD Story IDs
[V0.0.0] Aucune intégration paiement. Interfaces d'abstraction PAY mises en place (provider-agnostic), aucun provider concret câblé. CP v0.0.0 n'expose pas de flow paiement. -
[V0.0.1] Premier provider live (Paymetrust / Flutterwave / Orange Business - sélection procurement-driven, premier à signer le partenariat). Idempotency key obligatoire (MC-5, Redis 24h TTL). Webhook signature verification obligatoire (HMAC + clé publique provider). Webhook idempotent (idempotency = transaction_id provider). Réconciliation : webhook primaire + polling fallback. Échec webhook -> réconciliation manuelle (audit logged). Country-profile lookup depuis tenant country_code. PAY-001
[V0.0.2] Polling de statut + retry manuel côté client (pas d'auto-retry serveur). PAY-002
[V0.1.0] Auto-retry minimal + hooks d'observabilité (tuning pre-V1). PAY-003
[V1] Spec V1 complète telle qu'écrite ci-dessous (multi-provider production-grade, auto-retry complet + manual override, multi-country actif). -

Verrouillé V0.0.0 onwards (cf. prd-v0.md § 4 ligne PAY + § 5.5) : - Comptes marchands officiels uniquement (jamais de numéros mobile-money personnels). Contrat signé + conformité réglementaire par provider, sans exception. - Idempotency (dès v0.0.1) : endpoint /api/payments/... accepte une idempotency key (UUID), stockée Redis 24h TTL ; duplicate key -> réponse cachée (même charge ID). - Webhook signature verification (dès v0.0.1) : provider webhook HMAC vérifiée avant tout traitement ; signature invalide -> 400 ; handler idempotent. - PAY expose /api/payments/... (D5) ; CP appelle PAY directement (pas de relai via OP). Audit-log via outbox (D7).

Verrouillé V0 : sélection du provider est procurement-driven, pas technique (V0-OQ-01 résolu). L'abstraction PAY reste provider-agnostic pour que la décision soit réversible. La pré-spec V0.0.4 (BYO DB) ne change rien à PAY : tous les paiements et reconciliation logs sont écrits sur le tenant DB resolved par le datasource resolver.


Part A - Product Requirements

A.1 Executive Summary

The payment module owns every part of the renewal-payment money path that does NOT belong to the customer-portal UX or the operator console: PSP/aggregator adapter integration, transaction lifecycle, idempotency and double-charge prevention, webhook ingestion + signature verification, scheduled reconciliation polling, broker-branded quittance generation and storage, cancel/return URL handling, payment-status event publication to the rest of the platform, adapter credential vault, per-adapter circuit breaker and health monitoring, no-rail degradation gates, and the operator surfaces required to investigate, reconcile and report on payments.

PAY is invisible to the customer (CP renders the rail picker, the summary, the pending screen, the reçu - PAY supplies them with state and PDF blobs) and deeply present to the operator (every cabinet looks at PAY's payments list, daily settlement, and anomaly entries every working day).

The five problems PAY exists to solve in V1:

  1. Replace informal collections with official merchant channels while keeping the platform OUT of the funds path. Funds flow customer → PSP → broker merchant account directly. The platform never holds, escrows, or payouts customer money - preserving the "technical service provider" positioning under CIMA Reg. n°001-2024 art. 3 pt.17 and avoiding BCEAO Instruction 008-05-2015 obligations on payment intermediaries.

  2. Survive the messy reality of PSP partnerships in CI. Partnerships are slow and unpredictable; we sign whoever accepts first. PAY ships with a pluggable adapter architecture and per-rail routing so the platform can grow new adapters (Orange Money direct, Wave direct, MTN MoMo direct, Flutterwave aggregator, CinetPay aggregator…) without rewriting the customer flow. A "rail" the customer sees (Orange Money / Wave / MoMo / Carte) is decoupled from the "adapter" that serves it; routing picks the cheapest active adapter per rail.

  3. Never double-charge, even on slow 3G with intermittent connectivity, even when webhooks are lost, even when the customer browser dies mid-redirect. Idempotency key bound to {customer_session, contracts, amount}, honored for 72h (matching the signed-link TTL). Reconciliation by webhook (primary) + scheduled status-poll (backup) + operator manual reconcile (last resort).

  4. Generate broker-branded quittances that satisfy CIMA Reg. n°001-2024 art. 26 informational obligations, are usable as accounting evidence in CI broker books, and are retained 10 years per art. 37 + BCEAO 008-05-2015 arts. 7+26.

  5. Give the operator console a complete, calm payment surface: payments list with filters, payment detail with full timeline + raw provider events (redacted), daily settlement report per agency, manual reconcile action on EXPIRED_PENDING, mark-contested action - with audit trail on every operator move.

What PAY explicitly does NOT do in V1: - The rail picker UI, summary screen, reçu screen - owned by CP. - The campaign send UI and anomaly center UI - owned by OP. - The merchant credentials admin UI - owned by TENANT. - The audit-log persistence - PAY emits, AUDIT persists. - The notification dispatch (SMS/WA receipt push) - PAY emits PaymentConfirmed, NOTIF dispatches. - Refund orchestration - out of V1. - Partial-amount payment per contract - out of V1. - Cards-direct integration without an aggregator - out of V1.


A.2 Personas

Persona Side Role w.r.t. PAY
Customer (assuré) [CUSTOMER] Triggers a payment from CP; never interacts with PAY's UI directly. Sees the broker's quittance after CONFIRMED.
Branch operator [OPERATOR] Lives in the OP console payments list and settlement report. Investigates anomalies via the central anomaly queue. Manually reconciles EXPIRED_PENDING transactions with the PSP back-office reference. Marks contested payments.
Cabinet admin [ADMIN] Configures payment adapters in TENANT > Paramètres > Paiements (PAY exposes the API). Pastes/rotates merchant credentials (Option A direct PSPs) or completes sub-merchant onboarding (Option B aggregators). Toggles rails on/off. Tests adapter connectivity.
Cabinet supervisor / management [OPERATOR] Consumes the daily settlement report for accounting reconciliation against the PSP back-office.
PSP / Aggregator [BACKEND] Receives initiation calls; sends webhooks; answers status queries. Verified by signature, IP allowlist, replay window.
Platform support / SRE [BACKEND] Reads adapter health pills, investigates circuit-open events, assists tenants on credential rotation.

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

The stories below are grouped by surface. Each is V1 unless tagged otherwise.

Adapter onboarding (TENANT-driven, PAY API)

PAY-US-01 - Cabinet admin configures a direct-PSP adapter (Option A) [V1] [ADMIN] [BACKEND]

As a cabinet admin who already holds a merchant contract with Orange Money, I want to paste my merchant credentials in the platform so that customer payments to Orange Money flow directly into my merchant wallet.

  • Given an admin in TENANT > Paramètres > Paiements, when I open the « Ajouter un adaptateur » dialog and pick "Orange Money CI (direct)", then the form requests merchant_id, api_key, api_secret, webhook_secret; pastes are masked; the form refuses to save until a "Tester la connexion" probe succeeds against the adapter's healthcheck endpoint.
  • Given I save successfully, when the row is created, then secrets are persisted encrypted at rest (envelope-encrypted DEK, KEK from environment/KMS); plaintext is never logged; AUDIT receives tenant_payment_adapter_configured with tenant_id, adapter_code='OM_CI_DIRECT', settlement_mode='BYO_MERCHANT', configured_by_user_id.
  • Given the adapter is created, when I look at the rail picker preview, then the "Orange Money" rail is now eligible for routing; if it is the only adapter eligible for that rail, it is selected; otherwise per-rail routing picks the cheapest active adapter (R-PAY-040).

PAY-US-02 - Cabinet admin onboards an aggregator adapter (Option B sub-merchant) [V1] [ADMIN] [BACKEND]

As a cabinet admin without an Orange Money merchant contract, I want to onboard via the platform's aggregator partnership so that I can take payments today.

  • Given an admin opens « Ajouter un adaptateur » and picks "Flutterwave (agrégateur, sous-marchand)", when the form opens, then I provide my legal info (legal_name, RCCM, agrément if any) and a payout target (mobile_money_number for an OM business wallet OR iban for a bank account); credentials are NOT requested.
  • Given I submit the form, when PAY calls the aggregator's sub-merchant provisioning API, then the resulting sub_merchant_id + sub_account_id is stored on the tenant adapter row; AUDIT receives tenant_payment_subaccount_provisioned; if the aggregator does not offer direct-settlement to the sub-account, PAY refuses to activate (R-PAY-002 hard rule: NEVER pooled settlement).
  • Given the sub-merchant is provisioned, when I activate it, then every rail this aggregator can serve (declared in adapter capability metadata) becomes eligible for routing.

PAY-US-03 - Cabinet admin enables/disables a rail [V1] [ADMIN]

As a cabinet admin, I want to enable or disable individual rails (not just adapters) so I can pause Orange Money during a fraud incident without losing card payments.

  • Given the « Paramètres > Paiements » page lists the four rails (Orange Money / Wave / MoMo / Carte) with toggles, when I toggle "Wave" off, then the rail is removed from the customer-portal rail picker within 60 s (cache TTL); AUDIT receives tenant_payment_rail_disabled.
  • Given I disable the last enabled rail of the tenant, when I confirm, then a warning banner appears: « Aucun moyen de paiement actif. Les liens existants afficheront un message de paiement indisponible et les nouvelles campagnes seront bloquées. »

Customer-payment initiation (CP → PAY)

PAY-US-04 - Initiate a transaction with idempotency [V1] [CUSTOMER] [BACKEND]

As CP on behalf of the customer, I want to start a transaction in a way that survives a slow-3G retry without ever creating two PSP debits.

  • Given CP calls POST /pay/transactions { tenant_id, customer_session_id, contract_ids[], amount_total_minor, currency='XOF', rail, idempotency_key, return_url, cancel_url }, when the key has not been seen in the last 72 h, then PAY (a) opens a payment_attempt row in status CREATED, (b) resolves the active adapter for the rail via routing table, (c) calls the adapter's init API, (d) on success transitions to PENDING with transaction_reference + redirect_url, (e) returns { transaction_reference, redirect_url } to CP within 10 s.
  • Given CP retries POST /pay/transactions with the SAME idempotency_key within 72 h, when PAY receives it, then it returns the EXISTING transaction's current { transaction_reference, redirect_url, status } - no second PSP call, no second payment_attempt row.
  • Given the rail's resolved adapter is in OPEN circuit (R-PAY-052) OR the rail is disabled, when PAY receives the call, then it returns HTTP 503 with { reason: 'NO_ACTIVE_RAIL', mapped_reason_fr: 'Le service de paiement est temporairement indisponible.' }; CP routes to its no-rail degradation surface.
  • Given the adapter init fails with a 4xx (bad credentials, malformed request) or 5xx (provider unavailable), when PAY receives the failure, then it transitions the attempt to terminal INITIATION_FAILED with provider_error_code; circuit-breaker counter increments; CP gets a typed failure mapped to French copy.

PAY-US-05 - Customer status polling endpoint [V1] [CUSTOMER] [BACKEND]

As CP, I want a low-cost status endpoint so the customer's CP-08 pending screen can poll for resolution.

  • Given CP calls GET /pay/transactions/{ref}/status with the customer's session token, when PAY validates the session owns the transaction (R-PAY-031 anti-cross-session), then it returns { status, provider_error_code?, mapped_reason_fr? } from the local cache (<= 100 ms p95).
  • Given the transaction is PENDING, when the poll fires, then PAY emits customer_payment_status_polled with transaction_reference, poll_index, status_returned; status reflects the latest local-DB state (NOT a fresh PSP call - those are scheduled by R-PAY-060).
  • Given the session does not own the transaction (cross-customer attempt), when PAY validates, then it returns HTTP 404 (NEVER 403 - leaks existence) and emits customer_payment_status_cross_session_attempt with full forensic fields.

Webhook ingestion

PAY-US-06 - Adapter webhook ingestion with signature + replay protection [V1] [BACKEND]

As an adapter, I want to notify PAY of a transaction's resolution; PAY accepts only verified, fresh, non-replayed events.

  • Given an adapter posts to POST /pay/webhooks/{adapter_code} with X-Signature and X-Timestamp, when PAY receives it, then it (a) verifies the HMAC against the per-tenant webhook_secret, (b) checks the timestamp window (±5 min vs server now), (c) checks the source IP against the adapter's published allowlist when one exists, (d) deduplicates against webhook_events.event_id (idempotent processing).
  • Given signature, timestamp and IP all pass, when the event is new, then PAY persists webhook_events (raw payload, redacted of sensitive fields), updates the payment_transactions row, transitions state, emits the appropriate domain event (PaymentConfirmed / PaymentFailed), and acks 200 within 2 s p95.
  • Given signature fails OR timestamp out of window OR IP not allowlisted, when PAY receives the request, then it returns 401, AUDIT receives webhook_signature_failed with adapter_code, source_ip, reason_code, and the central anomaly queue receives an entry; an SRE alert fires on the 5th failure inside 1 hour.
  • Given the same event_id arrives twice (adapter retry after a slow ack), when the dedup check fires, then PAY returns 200 immediately, persists no duplicate, audits webhook_event_replayed.

Reconciliation

PAY-US-07 - Scheduled reconciliation poll [V1] [BACKEND]

As the platform, I want to actively poll the adapter for transactions still in PENDING so a lost webhook never leaves a transaction stuck.

  • Given a transaction has been in PENDING for any amount of time, when the reconciliation scheduler fires (cron @ adaptive cadence per R-PAY-060), then PAY calls the adapter's status endpoint, updates the local row, transitions state if changed, emits the relevant domain event.
  • Given a transaction has been in PENDING for more than 24 h despite polling, when the next scheduler tick observes it, then PAY transitions it to terminal EXPIRED_PENDING, emits PaymentExpiredPending, files an entry in the central OP anomaly queue.
  • Given the adapter is in OPEN circuit, when the scheduler tries to poll it, then it skips and logs; circuit half-open probes resume polling on success.

PAY-US-08 - Operator manual reconcile on EXPIRED_PENDING [V1] [OPERATOR] [BACKEND]

As an operator looking at an EXPIRED_PENDING entry in the anomaly queue, I want to reflect the real outcome from my PSP back-office without engineering escalation.

  • Given an operator opens an EXPIRED_PENDING payment in the OP console, when they click « Marquer CONFIRMED » or « Marquer FAILED », then a modal requires a free-text PSP back-office reference (mandatory) and an optional note; submit transitions the transaction to terminal CONFIRMED or FAILED; PAY emits PaymentManuallyReconciled (which downstream listeners treat exactly like a webhook-driven event - quittance generation, NOTIF dispatch, OP file-status update); AUDIT receives payment_manually_reconciled with operator_user_id, action, psp_back_office_reference, note.
  • Given the operator marks CONFIRMED, when the event fires, then the quittance is generated, NOTIF dispatches the receipt push, OP marks the file payment_status=PAID - exactly as if the webhook had arrived.

PAY-US-09 - Operator marks a payment contested [V1] [OPERATOR]

As an operator handling a customer dispute received offline (per CP-07's contestation banner), I want to flag the payment in the system without moving any money.

  • Given an operator opens a CONFIRMED payment in the OP console, when they click « Marquer contesté », then a modal requests a contestation reason from a fixed list (CUSTOMER_DENIES, WRONG_AMOUNT, DUPLICATE_PAID, WRONG_CONTRACT, OTHER) + free-text note; submit creates a dispute linked to the payment with status OPEN; AUDIT receives payment_dispute_opened.
  • Given an operator resolves a dispute, when they choose REFUNDED_OFFLINE or DISPUTE_REJECTED, then the dispute closes, the payment row carries final_outcome reflecting the result, the contract payment_status is updated accordingly (refunded → file returns to UNPAID); AUDIT receives payment_dispute_resolved.

Quittance & confirmation

PAY-US-10 - Generate broker-branded quittance on CONFIRMED [V1] [BACKEND]

As the platform, I want every confirmed payment to produce N broker-branded quittances (one per contract) plus one master payment-confirmation receipt, ready to download in CP.

  • Given PaymentConfirmed fires, when the quittance generator handles it, then for each contract in the transaction it generates a PDF with the four blocks (broker identity, contract identity, payment block, mentions légales) per A.4 quittance content; assigns a tenant-scoped sequential number <TENANT_CODE>-<YYYY>-<NNNNNN> from a per-tenant per-year counter (atomic); persists the PDF in MinIO under quittances/<tenant_id>/<YYYY>/<quittance_number>.pdf; persists a quittances row referencing the file.
  • Given all per-contract quittances generated, when the generator completes, then it generates one master payment-confirmation PDF listing transaction reference + total + per-contract breakdown + rail; persists similarly with <TENANT_CODE>-<YYYY>-RECAP-<NNNNNN>.
  • Given quittances are persisted, when CP requests GET /pay/transactions/{ref}/quittances, then PAY returns short-lived signed URLs (TTL 5 min, regeneratable) for each PDF, ready for the customer to download.
  • Given the customer revisits via a still-valid signed link OR via the post-revoke per-transaction stable URL (per CP-prd CP-OQ-… stable URL), when they hit the download endpoint, then PAY re-issues a fresh signed URL on demand without regenerating the PDF.

PAY-US-11 - PSP cancel URL handling [V1] [CUSTOMER] [BACKEND]

As a customer who taps « Annuler » at the PSP page, I want to land back on the summary with my selection intact.

  • Given the adapter redirects to PAY's cancel URL with a transaction reference, when PAY receives the hit, then it transitions the attempt to terminal CANCELLED_BY_USER, emits PaymentCancelledByUser, redirects the customer back to CP's summary URL with ?session=...&reason=user_cancelled; CP restores the summary screen with selection preserved and consent box re-cleared (per R-CP-010); AUDIT receives customer_payment_cancelled_at_psp.

Operator surfaces

PAY-US-12 - Operator payments list [V1] [OPERATOR]

As an operator, I want a sortable, filterable list of every payment for my agency so I can find any transaction quickly.

  • Given an operator opens OP > Paiements, when the list loads, then it shows columns: date, file (customer name + ref), contracts (count + click-to-expand), amount, rail, status pill (FR), transaction reference, last update; default sort = date desc; default filter = last 30 days, this agency.
  • Given I apply filters (date range, agency, status, rail, free-text search by reference or customer name), when the filter applies, then the list re-paginates server-side; export to CSV is available with the same filters.
  • Given I click a row, when the detail page opens, then PAY-US-13 surface renders.

PAY-US-13 - Operator payment detail with full timeline [V1] [OPERATOR]

As an operator investigating a support case, I want the full forensic timeline of a payment.

  • Given I open a payment detail, when the page renders, then I see (a) a status timeline (CREATED → PENDING → CONFIRMED, with timestamps + actor + source - webhook / poll / manual / cancel), (b) every webhook event raw payload (redacted: no full PII, no full credit-card data), (c) every poll executed (timestamp, status returned), (d) every audit event for this transaction, (e) the linked contracts + their payment_status, (f) the generated quittances + download links, (g) action buttons (Marquer contesté, Régénérer le reçu - only on CONFIRMED).
  • Given the transaction is EXPIRED_PENDING, when the detail renders, then the manual reconcile actions of PAY-US-08 are surfaced inline.

PAY-US-14 - Daily settlement report per agency [V1] [OPERATOR]

As a cabinet supervisor, I want a daily report of payments received per agency that I can reconcile against my PSP back-office.

  • Given the supervisor opens OP > Paiements > Rapport de règlement, when they pick a date and agency, then PAY produces a report showing total received per rail (count + sum), per-tenant total, list of transactions in EXPIRED_PENDING, list of disputes opened/closed, list of refunds (offline-flagged).
  • Given the report is generated, when they click « Télécharger », then PAY produces a PDF (printable for archival) and a CSV (machine-readable for ERP import); both stamped with broker identity, period, generation timestamp + operator user id; AUDIT receives settlement_report_generated.

No-rail degradation

PAY-US-15 - OP campaign-validation gate [V1] [OPERATOR] [BACKEND]

As the OP campaign-validation step, I want to block sending if the tenant has zero healthy rails so we never ship dead links.

  • Given an operator clicks « Valider l'envoi » on a campaign, when OP calls GET /pay/tenants/{id}/rail-availability, then PAY returns { rails: [{rail, available: bool, reason_if_unavailable}] } resolving CONFIG, DISABLED and CIRCUIT_OPEN scenarios.
  • Given zero rails are available, when OP receives the response, then the « Valider » button is disabled and a banner shows the scenario-specific French message (per R-PAY-070); the operator cannot proceed.

PAY-US-16 - CP runtime degradation banner [V1] [CUSTOMER] [BACKEND]

As a customer opening a previously-issued link, I want a clear human path forward when the platform cannot take a payment right now.

  • Given CP calls GET /pay/tenants/{id}/rail-availability on portal load, when zero rails are available, then CP renders the customer-portal degradation surface (CP module owns the UI, PAY supplies the signal): contract list shown read-only, rail picker hidden, banner « Le paiement en ligne est temporairement indisponible. Le cabinet vous contactera pour finaliser le règlement. » + click-to-call + click-to-WhatsApp; PAY emits customer_payment_blocked_no_rail with reason_category; OperatorAlert event fires for the cabinet (PAY-US-15 dashboard).

Stale attempt cleanup

PAY-US-17 - Auto-close stale CREATED attempts [V1] [BACKEND]

As the platform, I want CREATED attempts that never reached the PSP to auto-close so a customer's session is not blocked indefinitely.

  • Given a payment_attempt is in status CREATED (PSP never called) AND created more than 1 h ago, when the cleanup scheduler fires (every 5 min), then it transitions the attempt to terminal TIMED_OUT_PRE_PSP, frees the customer session's inflight_transaction_id slot, emits payment_attempt_timed_out_pre_psp for AUDIT; no PSP call ever occurred so no money to reverse.

A.4 Quittance content (CIMA Reg. n°001-2024 art. 26 mandatory information)

Every quittance MUST include:

Block Fields Source
Broker identity legal_name, commercial_name, agrément number, RCCM, head office address, agency address, telephone, email TENANT (frozen onto quittance at issuance)
Contract identity contract_reference, branch (auto / MRH / vie / …), insured_name, period_covered (from → to), policy_number_if_any Contract record at time of payment (frozen)
Payment block amount_xof_numeric, amount_xof_spelled (« cent cinquante mille francs CFA »), payment_date, transaction_reference, rail_used, quittance_number, cabinet sequential year-seq PAY
Mentions légales Procédure de contestation 30j (with agency phone), notice de protection des données personnelles, NOTIF-supplied cross-border block (mirrors customer-portal A.6 mentions block), CGU + conditions du service de paiement TENANT (with version hash captured at issuance)

Quittance content is frozen at issuance time - future contract or tenant edits do NOT mutate past quittances. Each quittance carries mentions_legales_version_hash so a regulator can reconstruct exactly what the customer saw.

The master payment-confirmation receipt mirrors the broker-identity + payment block, lists the per-contract breakdown, and bears its own quittance number <TENANT>-<YYYY>-RECAP-<NNNNNN>.


A.5 Non-Functional Requirements

ID Requirement Side
PAY-NFR-01 Customer-facing endpoints (POST /pay/transactions, GET /pay/transactions/{ref}/status, cancel/return URLs) p95 ≤ 1.5 s on Slow 3G profile (ties to CP-NFR-05). [CUSTOMER] [BACKEND]
PAY-NFR-02 POST /pay/transactions hard server-side timeout 15 s; status polling endpoint hard timeout 5 s; webhook handler hard timeout 10 s. [BACKEND]
PAY-NFR-03 Adapter init call hard timeout 10 s; adapter status call hard timeout 5 s; adapter healthcheck hard timeout 3 s. [BACKEND]
PAY-NFR-04 Webhook handler ack within 2 s p95 (defer heavy downstream work to event listeners - avoid PSP retries). [BACKEND]
PAY-NFR-05 Reconciliation poll cadence per R-PAY-060: 60 s for the first 30 min, 5 min for the next 6 h, 30 min for the next 18 h, then EXPIRED_PENDING after 24 h. Per-adapter override allowed via config. [BACKEND]
PAY-NFR-06 Idempotency-key window: 72 h matching the signed-link TTL. After 72 h the key is rotated out. [BACKEND]
PAY-NFR-07 Stale CREATED attempt cleanup: cron every 5 min, sweep WHERE status=CREATED AND created_at < now-1h. [BACKEND]
PAY-NFR-08 Per-adapter circuit breaker: OPEN after N consecutive failures (default 5) OR error rate > 50% over a 5-min rolling window (min 10 calls); HALF-OPEN probes every 60 s; CLOSE after 3 consecutive probe successes. [BACKEND]
PAY-NFR-09 Webhook signature: HMAC-SHA256 minimum; per-tenant webhook_secret. [BACKEND]
PAY-NFR-10 Webhook timestamp window: ±5 min vs server now. [BACKEND]
PAY-NFR-11 Webhook IP allowlist enforced when the adapter publishes one (configurable per adapter; some aggregators do not). [BACKEND]
PAY-NFR-12 Adapter credentials at rest: AES-256-GCM envelope encryption; DEK wrapped by KEK held in env/KMS; secret fields NEVER logged in any form (request, response, error, stack trace). [BACKEND]
PAY-NFR-13 Quittance generation: < 3 s per contract on a single instance; queued if PaymentConfirmed listeners are saturated. [BACKEND]
PAY-NFR-14 Quittance signed URL TTL: 5 min, regeneratable on demand. [CUSTOMER] [OPERATOR]
PAY-NFR-15 Daily settlement report generation: < 30 s for an agency with 1000 daily transactions. [OPERATOR]
PAY-NFR-16 Per-tenant per-year quittance sequence: atomic monotonic; survives migrations; never reuses a number. [BACKEND]
PAY-NFR-17 Domain events use Spring Modulith @ApplicationModuleListener with persistent event_outbox; at-least-once delivery; consumer-side idempotency by event id. [BACKEND]
PAY-NFR-18 All tenant-scoped tables carry tenant_id (and agency_id derived from the contract where applicable); tenant-aware datasource resolver from day one (V1 PRD §27 + CLAUDE.md tenant model). [BACKEND]
PAY-NFR-19 XOF stored as integer minor units (XOF has no decimals so minor unit = the unit). Display uses space as thousands separator, no decimals. [BACKEND] [CUSTOMER] [OPERATOR]
PAY-NFR-20 Retention: payment_transactions + payment_attempts + quittances + webhook_events (header + status fields) = 10 years; webhook_events raw payload = 2 years; idempotency_keys = 90 days; MinIO quittance PDFs = 10 years (cold tier after 1 year). [BACKEND]

A.6 V1 vs V2 vs V3 Scope

Capability V1 V2 V3
Hosted-redirect to PSP / aggregator ✅ + multi-provider failover live ✅ + embedded checkout option
Pluggable adapter architecture (per-rail routing among active adapters) ✅ + per-tenant routing override ✅ + dynamic ML routing
Active adapters at launch: any of {Orange Money direct, Wave direct, MTN MoMo direct, Flutterwave, CinetPay, Move Money} "Whichever signs first" - ≥ 1 required configurable + at least one direct PSP per active rail full marketplace
Settlement model: hybrid Option A (BYO merchant) for direct PSPs, Option B (sub-merchant + direct settlement) for aggregators; pooled settlement HARD-FORBIDDEN
Idempotency key (72 h)
Webhook signature + timestamp + IP allowlist + dedup ✅ + automated key rotation
Reconciliation: webhook + scheduled poll (adaptive) + operator manual ✅ + circuit-aware adaptive poll
Per-adapter circuit breaker + health pills in OP ✅ + auto-failover to backup adapter on OPEN ✅ + SLA dashboards per adapter
Broker-branded quittance per contract + master receipt ✅ + customizable templates ✅ + multi-brand per tenant
Tenant-scoped sequential quittance numbering per year ✅ + tenant-supplied range option
BCEAO mobile-money cap: PSP rejects, mapped FR copy re-evaluate (auto-card-fallback) ✅ + auto-split research
Refunds (automated via adapter API)
Partial-amount payment per contract
Operator marks-contested (offline refund tracking) ✅ + dispute workflow
OP payments list + detail + daily settlement report ✅ + multi-agency consolidation ✅ + group consolidation
PAY KPIs (own dashboard) ❌ (OP owns dashboards from PAY events) ✅ + analytics module
Adapter credentials vault: encrypted at rest in Postgres + KMS-wrapped DEK ✅ migrate to external secrets manager (Vault / AWS SM)
Multi-currency (XAF for CEMAC, etc.) ❌ XOF only ✅ via country-profile
Tax info at customer payment time (CM, GA) ❌ V1 = CI only; CI does not require ✅ via country-profile
FNE / fiscal e-invoicing on platform→tenant invoices N/A (BILL module concern, not PAY) N/A N/A
Cross-tenant payment views (group consolidation) partial
Public PSP API for partners

A.7 Open Questions

ID Question Resolution path
PAY-OQ-01 Which adapter actually signs first? Orange Money direct, Flutterwave, CinetPay, Wave direct, MoMo direct, Move Money. The choice affects the active adapter at launch but not the architecture (per-rail routing absorbs whichever wins). Commercial + business-development; not blocking design.
PAY-OQ-02 D14 - PSP merchant T&Cs not yet pinned for OM/Wave/MoMo CI (audit memo CHECK 5 priority CRITICAL #4). The exact contractual T&Cs must be obtained before each adapter goes live to confirm idempotency clauses, settlement rules, dispute timing, fee schedule. Founder + legal counsel; per-adapter, before that adapter is activated.
PAY-OQ-03 D15 - Idempotency / double-charge contractual basis on PSP side (audit memo CHECK 5 priority CRITICAL #5). PSPs carry no explicit BCEAO obligation; defense is contractual + technical. PAY implements idempotency defensively from day one (R-PAY-020 ff.); a contractual clause is still required per adapter contract. Legal + commercial; per adapter.
PAY-OQ-04 E2 - Qualified e-signature for substantial contract modification (audit memo CHECK 5 priority HIGH #7). The legal opinion is INCERTAIN - avocat requis. PAY's quittance + payment confirmation is treated as evidence of payment, NOT as evidence of contract modification. V1 is scoped to renewal-as-is: no coverage change, no premium adjustment, no rider. The PRD ANALYST flags this as a constraint on the PAY scope. Legal opinion before enabling any "modify-then-pay" flow.
PAY-OQ-05 Magic-link civil opposability in CI (audit memo CHECK 5 priority HIGH #6). The legal admissibility of the signed link as evidence of consent is INCERTAIN. PAY emits payment_attempt_evidence audit events with the customer's session-id, IP, user-agent, consent_content_version (from CP) so the proof bundle is reconstructable; final admissibility requires legal validation. Same legal opinion as CP-OQ-05.
PAY-OQ-06 Sub-merchant KYC delegation by aggregators. Some aggregators (Flutterwave) delegate sub-merchant KYC to the master merchant (us). This creates LCB-FT obligations the platform may not want. Need to be confirmed per-aggregator before activating Option B mode. Compliance + legal review per aggregator contract.
PAY-OQ-07 Operator-side daily settlement reconciliation format. We assume CSV + PDF; cabinets may want their accounting tool's specific format (Sage, Excel macros, etc.). Pilot feedback. Defer column ordering to first cabinet onboarding.
PAY-OQ-08 Period transitoire CIMA n°001-2024 expired 01/01/2026 (audit memo CHECK 6 #6). Whether the platform PCS qualifies as pre-existing operator or nouvel entrant changes the regulatory exposure. Legal opinion; affects all V1 modules, not just PAY.
PAY-OQ-09 Anomaly classification for webhook_signature_failed. Should this be an OP anomaly (operator action) or an SRE alert (engineering action)? Probably both - the op needs visibility, SRE needs the page. Confirm with OP module ANALYST.

A.8 Temporarily Validated

These rules are implemented as-is and tracked for later confirmation.

  • PAY-TV-01 - Idempotency clause is implemented unilaterally at the platform layer. No PSP currently confirms reciprocal idempotency in their public T&Cs. The defense is technical (key honored 72h, PSP retries deduplicated by event_id, status-poll converges to single truth). Reviewed against each adapter contract as it lands (PAY-OQ-03).
  • PAY-TV-02 - Quittance numbering format <TENANT_CODE>-<YYYY>-<NNNNNN> is treated as compliant CI broker accounting evidence. No CIMA / DGI text mandates a specific format. If a tenant's accounting system requires a different format, V2 adds the tenant-supplied range option (per A.6).
  • PAY-TV-03 - Master receipt sequential format <TENANT>-<YYYY>-RECAP-<NNNNNN> shares the same per-tenant per-year counter as quittances (RECAP- prefix differentiates). If accounting practice requires a separate sequence, V1.1 splits.
  • PAY-TV-04 - webhook_events raw payload retention of 2 years (vs 10 years for headers) is treated as sufficient. CIMA art 37 + BCEAO arts 7+26 require evidence of monetary operations; the structured event header is the evidence; the raw payload is debug-grade.
  • PAY-TV-05 - Adapter health probes are HTTP-level only (no synthetic transaction). Synthetic transactions per adapter are V2 (cost; some adapters charge per call).
  • PAY-TV-06 - Operator-marked refund (PAY-US-09 outcome REFUNDED_OFFLINE) updates the contract payment_status back to UNPAID, returning the file to the renewal queue. This is treated as the V1 behavior; if a cabinet wants to keep the file out of the queue (e.g. customer cancelled the contract entirely), the operator uses a separate "Marquer non-renouvelable" action in the OP console (out of PAY scope).
  • PAY-TV-07 - Aggregator sub-merchant KYC obligations delegated to us are accepted at face value at activation; the platform commits to enforcing the aggregator's documented KYC checklist on tenant onboarding via TENANT module. Per-aggregator confirmation pending PAY-OQ-06.
  • PAY-TV-08 - EXPIRED_PENDING 24h horizon is platform-wide default; no per-adapter override in V1. If an adapter consistently resolves slower than 24h (e.g. some bank-transfer rails not in V1), V2 makes this configurable.
  • PAY-TV-09 - Daily settlement report's set of columns (per A.5 PAY-US-14) is the V1 baseline; ordering and column inclusion may change post-pilot per PAY-OQ-07.

Part B - Functional Specifications

B.1 Functional Rules (numbered)

Adapter abstraction & routing

  • R-PAY-001 - An adapter is a pluggable integration with one PSP or one aggregator. Each adapter declares: adapter_code (unique), adapter_kind ∈ {DIRECT_PSP, AGGREGATOR}, supported_rails[], settlement_mode ∈ {BYO_MERCHANT, SUB_MERCHANT_DIRECT_SETTLEMENT}, country_codes[], currencies[], fee_table (per rail per amount band).
  • R-PAY-002 - HARD RULE: an adapter with any settlement mode other than BYO_MERCHANT or SUB_MERCHANT_DIRECT_SETTLEMENT is REFUSED at registration. POOLED settlement is forbidden in V1 (would put the platform inside BCEAO Instruction 008-05-2015 scope).
  • R-PAY-003 - A rail is a customer-facing payment method. Rail catalog: ORANGE_MONEY, WAVE, MOMO, CARD. Rails are stable across adapters; new adapters add coverage to existing rails, not new rails.
  • R-PAY-004 - A tenant_payment_adapter row links a tenant to a configured adapter; carries either: (BYO_MERCHANT) the merchant credentials encrypted at rest, OR (SUB_MERCHANT_DIRECT_SETTLEMENT) the sub-account id + payout target.
  • R-PAY-005 - The tenant_payment_rail row links a tenant + rail and carries enabled: bool.
  • R-PAY-040 - Per-rail routing: when CP requests a transaction on rail R for tenant T, PAY computes the active adapter as the cheapest active adapter such that: (a) the tenant has a configured + non-faulty adapter row for it, (b) the rail is enabled, (c) the adapter circuit is CLOSED, (d) the adapter declares R in supported_rails, (e) the adapter's country_codes include the tenant's country. Ties break by adapter.priority (config). Result is cached per (tenant_id, rail) with TTL 60 s.
  • R-PAY-041 - Rail availability is scenario-classified: CONFIG (no adapter configured), DISABLED (adapter exists but rail toggled off), CIRCUIT_OPEN (adapter circuit OPEN), AVAILABLE. The scenario drives the FR copy shown to operator/customer (R-PAY-070).

Idempotency

  • R-PAY-020 - POST /pay/transactions accepts idempotency_key (UUIDv4). If an existing payment_attempt row has the same key AND was created less than 72 h ago, return the existing {transaction_reference, redirect_url, status} without calling the PSP.
  • R-PAY-021 - A new attempt is created under a UNIQUE constraint on (tenant_id, idempotency_key). Concurrent calls with the same key resolve atomically (database constraint + retry-with-read semantics).
  • R-PAY-022 - After 72 h, the key is dropped from the idempotency_keys table by a daily cleanup job. A replay after expiry creates a new attempt (signed-link is also expired by then).

Transaction lifecycle

  • R-PAY-030 - A payment_transaction is unique by (tenant_id, transaction_reference); carries the canonical state. A payment_attempt is the per-initiation record (one transaction may be born of one attempt; multiple attempts can be associated to one tenant payment session via separate idempotency keys after a failed retry).
  • R-PAY-031 - Cross-session anti-leak: every CP-side request (status, quittances, return URL) MUST verify the customer session owns the transaction. Mismatch returns 404 (never 403) and audits the attempt.
  • R-PAY-032 - A transaction in any non-terminal state can transition through {webhook, poll, manual_reconcile, cancel_url}. Source of the transition is recorded.

Initiation

  • R-PAY-033 - On POST /pay/transactions, PAY (a) resolves the adapter (R-PAY-040), (b) creates payment_attempt (status=CREATED), (c) calls adapter init with idempotency_key forwarded in adapter-protocol form, (d) on success transitions to PENDING with transaction_reference + redirect_url + provider, (e) on failure transitions to terminal INITIATION_FAILED with provider_error_code.
  • R-PAY-034 - Adapter init hard timeout 10 s (PAY-NFR-03). Timeout transitions to INITIATION_TIMEOUT (terminal). Circuit breaker counter increments.

Webhook

  • R-PAY-050 - POST /pay/webhooks/{adapter_code} is per-adapter. Signature header name + algorithm + timestamp header are adapter-specific (config). Default algorithm: HMAC-SHA256.
  • R-PAY-051 - Webhook handler order: signature check → timestamp window check → IP allowlist check → event_id dedup → state transition + event emission → 200. Failure of any check returns 401, audit, anomaly, alert.
  • R-PAY-052 - Per-adapter circuit breaker uses BOTH adapter init/poll outcomes AND webhook signature outcomes. 5 consecutive failures OR > 50% error rate over 5 min (with min 10 calls) opens the circuit. Probes every 60 s.

Reconciliation

  • R-PAY-060 - Adaptive poll cadence per transaction: every 60 s for the first 30 min in PENDING; every 5 min for the next 6 h; every 30 min for the next 18 h; then auto-transition to EXPIRED_PENDING and stop polling.
  • R-PAY-061 - Per-adapter cadence override allowed via config (some adapters resolve slower).
  • R-PAY-062 - Manual reconcile by operator emits PaymentManuallyReconciled which downstream listeners treat exactly like PaymentConfirmed / PaymentFailed (no special-casing).

No-rail degradation

  • R-PAY-070 - GET /pay/tenants/{id}/rail-availability returns one entry per rail with available + reason_if_unavailable + scenario_code ∈ {CONFIG, DISABLED, CIRCUIT_OPEN}. Scenario drives the FR copy:
  • CONFIG → « Aucun moyen de paiement configuré. »
  • DISABLED → « Tous les moyens de paiement sont désactivés. »
  • CIRCUIT_OPEN → « Les fournisseurs de paiement sont temporairement indisponibles. »
  • R-PAY-071 - OP campaign-validation gate: zero available rails ⇒ « Valider l'envoi » disabled with scenario banner.
  • R-PAY-072 - CP runtime degradation: zero available rails ⇒ portal renders the no-rail surface (CP responsibility).

Cap handling (BCEAO mobile-money wallet ceiling, customer side)

  • R-PAY-080 - PAY does NOT pre-check the cap. It lets the PSP reject. The provider error code is mapped to a French copy and surfaced to CP-09 (failed surface). Mapping: PLAFOND_DEPASSE → « Le plafond de votre portefeuille mobile money est atteint. Choisissez la carte bancaire ou contactez le cabinet. »
  • R-PAY-081 - The card rail has no e-money cap; if Card is available it remains a path for high-value payments without explicit guidance from PAY.

Cancel / return

  • R-PAY-090 - cancel_url handler transitions the attempt to CANCELLED_BY_USER (terminal); redirects to CP summary; emits event.
  • R-PAY-091 - return_url handler is owned by CP (per CP-prd CP-06); PAY exposes the status endpoint that CP queries on landing.

Quittance

  • R-PAY-100 - On PaymentConfirmed (or its synthetic equivalent from manual reconcile), PAY generates N per-contract quittances + 1 master receipt; persists PDFs to MinIO; persists quittances rows.
  • R-PAY-101 - Quittance numbering: per-tenant per-year monotonic counter. Format <TENANT_CODE>-<YYYY>-<NNNNNN>. Master uses <TENANT_CODE>-<YYYY>-RECAP-<NNNNNN> against the same counter.
  • R-PAY-102 - Quittance content is FROZEN at issuance (broker identity + contract identity + payment block + mentions légales) with mentions_legales_version_hash.
  • R-PAY-103 - Quittance signed-URL TTL 5 min; regeneratable via GET /pay/transactions/{ref}/quittances.
  • R-PAY-104 - A stable per-transaction URL (independent of the customer signed link) gives the customer access to the receipt after the link is auto-revoked by AUTH (per CP-prd cross-link). PAY persists the stable URL in the payment_transaction row; auth via short-lived signature regenerated on each request.

Refund / dispute

  • R-PAY-110 - V1 has NO automated refund through adapter APIs. Operator marks Marquer contesté to open a dispute. Refund happens out-of-band via the cabinet's PSP back-office.
  • R-PAY-111 - Dispute outcomes: REFUNDED_OFFLINE (cabinet refunded outside platform) → contract returns to UNPAID; DISPUTE_REJECTED (cabinet upheld the payment) → contract stays PAID; both audited.

Cleanup

  • R-PAY-120 - Cron every 5 min: WHERE payment_attempts.status=CREATED AND created_at < now-1h ⇒ transition to TIMED_OUT_PRE_PSP; free the customer session inflight slot.
  • R-PAY-121 - Daily cleanup of idempotency_keys WHERE expires_at < now.
  • R-PAY-122 - Annual archival job: webhook_events.raw_payload older than 2 years → null out the raw blob (keep header + parsed status fields); MinIO objects older than 1 year → cold tier (still retained 10 y total).

B.2 State Machines

Payment-attempt (CP ↔ PAY)

stateDiagram-v2
    [*] --> CREATED: POST /pay/transactions (idem key reserved)
    CREATED --> PENDING: adapter init success → transaction_reference + redirect_url
    CREATED --> INITIATION_FAILED: adapter init 4xx/5xx (terminal)
    CREATED --> INITIATION_TIMEOUT: adapter init > 10s (terminal)
    CREATED --> TIMED_OUT_PRE_PSP: cleanup cron > 1h (terminal)
    PENDING --> CONFIRMED: webhook | poll | manual_reconcile (terminal)
    PENDING --> FAILED: webhook | poll | manual_reconcile (terminal)
    PENDING --> CANCELLED_BY_USER: cancel_url hit (terminal)
    PENDING --> EXPIRED_PENDING: > 24h in PENDING despite polling (terminal)
    EXPIRED_PENDING --> CONFIRMED: operator manual_reconcile_confirm (terminal)
    EXPIRED_PENDING --> FAILED: operator manual_reconcile_fail (terminal)
    CONFIRMED --> [*]: quittance generated, NOTIF dispatched, OP file paid
    FAILED --> [*]
    CANCELLED_BY_USER --> [*]
    INITIATION_FAILED --> [*]
    INITIATION_TIMEOUT --> [*]
    TIMED_OUT_PRE_PSP --> [*]

Dispute (operator-driven, post-CONFIRMED)

stateDiagram-v2
    [*] --> OPEN: operator marks contesté on a CONFIRMED payment
    OPEN --> REFUNDED_OFFLINE: cabinet refunded out-of-band → contract UNPAID
    OPEN --> DISPUTE_REJECTED: cabinet upheld the payment → contract stays PAID
    REFUNDED_OFFLINE --> [*]
    DISPUTE_REJECTED --> [*]

Adapter circuit breaker

stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN: 5 consecutive failures OR error rate > 50% over 5min (min 10 calls)
    OPEN --> HALF_OPEN: probe every 60s
    HALF_OPEN --> CLOSED: 3 consecutive probe successes
    HALF_OPEN --> OPEN: any probe failure

B.3 Audit Events Emitted by PAY

PAY emits the following events to AUDIT (V1 PRD §18 compliant; field set per AUDIT module spec). Categories: - [CUSTOMER] customer-side action with tenant context; - [OPERATOR] operator action; - [ADMIN] cabinet admin action; - [BACKEND] system or PSP-driven event.

Event Side Key fields (in addition to common audit envelope)
tenant_payment_adapter_configured [ADMIN] tenant_id, adapter_code, settlement_mode, configured_by_user_id
tenant_payment_subaccount_provisioned [ADMIN] tenant_id, adapter_code, sub_merchant_id, payout_target_kind
tenant_payment_credential_rotated [ADMIN] tenant_id, adapter_code, rotated_by_user_id
tenant_payment_rail_enabled / tenant_payment_rail_disabled [ADMIN] tenant_id, rail, by_user_id
payment_attempt_created [BACKEND] tenant_id, customer_session_id, contract_ids[], amount_total_minor, currency, idempotency_key, rail, resolved_adapter_code
payment_attempt_initiation_failed [BACKEND] tenant_id, idempotency_key, adapter_code, provider_error_code, mapped_reason_fr
payment_attempt_timed_out_pre_psp [BACKEND] tenant_id, idempotency_key
customer_payment_initiated [CUSTOMER] (matches CP-prd) tenant_id, customer_session_id, transaction_reference, idempotency_key, provider, contract_ids[], amount_total_minor, currency
customer_payment_status_polled [BACKEND] transaction_reference, poll_index, status_returned
customer_payment_status_cross_session_attempt [BACKEND] transaction_reference, attempting_session_id, ip, user_agent
webhook_received [BACKEND] adapter_code, event_id, transaction_reference, status_in_payload, source_ip
webhook_signature_failed [BACKEND] adapter_code, source_ip, reason_code (sig / timestamp / ip / dedup)
webhook_event_replayed [BACKEND] adapter_code, event_id
customer_payment_confirmed [CUSTOMER] transaction_reference, contract_ids[], amount_total_minor, currency, source ∈ {webhook, poll, manual}
customer_payment_failed [CUSTOMER] transaction_reference, provider_error_code, mapped_reason_fr
customer_payment_expired_pending [BACKEND] transaction_reference
customer_payment_cancelled_at_psp [CUSTOMER] transaction_reference
customer_payment_blocked_no_rail [CUSTOMER] tenant_id, customer_session_id, scenario_code
payment_manually_reconciled [OPERATOR] transaction_reference, action ∈ {confirm, fail}, operator_user_id, psp_back_office_reference, note
payment_dispute_opened [OPERATOR] transaction_reference, operator_user_id, reason_code, note
payment_dispute_resolved [OPERATOR] transaction_reference, operator_user_id, outcome ∈ {refunded_offline, rejected}, note
quittance_generated [BACKEND] transaction_reference, contract_id (or RECAP), quittance_number, minio_object_key
quittance_url_signed [BACKEND] quittance_number, requesting_actor (customer / operator), ttl_seconds
settlement_report_generated [OPERATOR] tenant_id, agency_id, period, format ∈ {pdf, csv}, operator_user_id
adapter_circuit_opened / adapter_circuit_closed / adapter_circuit_half_open [BACKEND] adapter_code, trigger_metric, count

B.4 Decision Tables

Routing decision (R-PAY-040)

Tenant has adapter for rail R Rail enabled Adapter circuit Outcome
no - - rail unavailable, scenario CONFIG
yes no - rail unavailable, scenario DISABLED
yes yes OPEN rail unavailable, scenario CIRCUIT_OPEN
yes yes HALF_OPEN rail unavailable (probes only)
yes yes CLOSED pick adapter ; if multiple, lowest fee ; ties by adapter.priority

Webhook handling

Signature Timestamp window IP allowlist event_id seen Outcome
valid within ±5min allowed (or n/a) new process + 200 + emit domain event
valid within ±5min allowed (or n/a) dup 200 + audit webhook_event_replayed
invalid - - - 401 + audit webhook_signature_failed(reason=sig) + circuit++
valid out of window - - 401 + audit webhook_signature_failed(reason=timestamp)
valid within not allowed - 401 + audit webhook_signature_failed(reason=ip)

Provider error → French mapped reason (extends CP decision table)

Provider error class provider_error_code mapped_reason_fr
Insufficient balance SOLDE_INSUFFISANT « Solde insuffisant sur votre compte. »
Wrong code / refused CODE_REFUSE « Code refusé. Vérifiez votre code et réessayez. »
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. »
Wallet ceiling exceeded PLAFOND_DEPASSE « Le plafond de votre portefeuille mobile money est atteint. Choisissez la carte bancaire ou contactez le cabinet. »
Bad credentials (PAY → adapter) INITIATION_FAILED « Service de paiement indisponible. »
Adapter timeout INITIATION_TIMEOUT « Le service de paiement n'a pas répondu. Réessayez. »
No adapter available NO_ACTIVE_RAIL « Le paiement en ligne est temporairement indisponible. »
Other AUTRE « Le paiement n'a pas abouti. Réessayez ou contactez le cabinet. »

Manual reconcile outcomes (PAY-US-08)

Operator action Required input Final state Downstream effects
Marquer CONFIRMED psp_back_office_reference (mandatory), note (optional) CONFIRMED quittance generated, NOTIF dispatch, OP file PAID, audit payment_manually_reconciled(action=confirm, psp_ref)
Marquer FAILED psp_back_office_reference (mandatory), note (optional) FAILED OP file UNPAID + reminder eligible, audit payment_manually_reconciled(action=fail, psp_ref)

Dispute outcomes (PAY-US-09)

Operator action Final state Contract effect
REFUNDED_OFFLINE dispute closed, payment.final_outcome=REFUNDED_OFFLINE contract.payment_status → UNPAID (re-enters renewal queue)
DISPUTE_REJECTED dispute closed, payment.final_outcome=DISPUTE_REJECTED contract.payment_status stays PAID

B.5 Data Requirements

All tables tenant-scoped (carry tenant_id). All money columns are BIGINT minor units. All timestamps are TIMESTAMPTZ. Schema lives in the tenant DB (BYO-DB-compatible from day one - PAY-NFR-18).

tenant_payment_adapter

  • tenant_payment_adapter_id UUID PK
  • tenant_id UUID NOT NULL (tenant scope)
  • adapter_code TEXT NOT NULL - references global adapter catalog
  • settlement_mode TEXT NOT NULL CHECK IN (BYO_MERCHANT, SUB_MERCHANT_DIRECT_SETTLEMENT)
  • merchant_id_or_subaccount_id TEXT NOT NULL - public id; never secret
  • encrypted_secret BYTEA - AES-256-GCM, present only for BYO_MERCHANT
  • encrypted_webhook_secret BYTEA NOT NULL
  • dek_wrapped_by_kek_id TEXT - KEK identifier
  • payout_target_kind TEXT - MOBILE_MONEY_NUMBER | IBAN, only for SUB_MERCHANT_DIRECT_SETTLEMENT
  • payout_target_value TEXT - number or IBAN
  • priority INT DEFAULT 100 - used as tie-breaker in routing
  • created_at / created_by / last_rotated_at / last_rotated_by
  • UNIQUE (tenant_id, adapter_code)

tenant_payment_rail

  • tenant_id UUID NOT NULL
  • rail TEXT NOT NULL CHECK IN (ORANGE_MONEY, WAVE, MOMO, CARD)
  • enabled BOOLEAN NOT NULL DEFAULT TRUE
  • last_changed_at / last_changed_by
  • PK (tenant_id, rail)

payment_attempt

  • payment_attempt_id UUID PK
  • tenant_id UUID NOT NULL
  • agency_id UUID NOT NULL - derived from contract.agency_id
  • customer_session_id UUID NOT NULL - references customer_sessions
  • contract_ids UUID[] NOT NULL - frozen at attempt creation
  • amount_total_minor BIGINT NOT NULL - sum of contract amounts at attempt creation
  • currency TEXT NOT NULL DEFAULT 'XOF'
  • rail TEXT NOT NULL
  • resolved_adapter_code TEXT NOT NULL - adapter picked at routing time
  • idempotency_key UUID NOT NULL
  • status TEXT NOT NULL - see B.2 state machine values
  • transaction_reference TEXT - present from PENDING onwards
  • redirect_url TEXT - present from PENDING onwards
  • provider_error_code TEXT NULL
  • mapped_reason_fr TEXT NULL
  • created_at / last_state_change_at
  • UNIQUE (tenant_id, idempotency_key)
  • INDEX (tenant_id, status, last_state_change_at) - feeds the reconciliation poll job
  • INDEX (tenant_id, customer_session_id) - feeds CP cross-session anti-leak check

payment_transaction

The canonical state once an adapter has been called and a transaction_reference exists.

  • payment_transaction_id UUID PK (= matches the most recent attempt's payment_attempt_id for V1; V2 may diverge)
  • tenant_id UUID NOT NULL
  • agency_id UUID NOT NULL
  • transaction_reference TEXT NOT NULL - provider-assigned reference (canonical for cross-system lookups)
  • adapter_code TEXT NOT NULL
  • rail TEXT NOT NULL
  • amount_total_minor BIGINT NOT NULL
  • currency TEXT NOT NULL DEFAULT 'XOF'
  • status TEXT NOT NULL - mirrors attempt state machine (terminal states only post-resolution)
  • final_outcome TEXT NULL - null | REFUNDED_OFFLINE | DISPUTE_REJECTED (post-dispute resolution)
  • psp_back_office_reference TEXT NULL - populated on manual_reconcile
  • last_webhook_event_id TEXT NULL
  • created_at / confirmed_at / last_state_change_at
  • stable_receipt_url_token TEXT - opaque token used in the post-revoke stable URL
  • UNIQUE (tenant_id, transaction_reference)
  • INDEX (tenant_id, agency_id, created_at) - feeds OP payments list + settlement report

quittance

  • quittance_id UUID PK
  • tenant_id UUID NOT NULL
  • agency_id UUID NOT NULL
  • payment_transaction_id UUID NOT NULL FK
  • contract_id UUID NULL - null on the master receipt (RECAP-)
  • quittance_kind TEXT NOT NULL CHECK IN (PER_CONTRACT, MASTER_RECAP)
  • quittance_number TEXT NOT NULL - <TENANT>-<YYYY>-<NNNNNN> or <TENANT>-<YYYY>-RECAP-<NNNNNN>
  • mentions_legales_version_hash TEXT NOT NULL
  • minio_object_key TEXT NOT NULL
  • generated_at TIMESTAMPTZ NOT NULL
  • UNIQUE (tenant_id, quittance_number)
  • UNIQUE (tenant_id, payment_transaction_id, contract_id) - at most one per-contract quittance per (transaction, contract); separate constraint for master via contract_id IS NULL partial unique

tenant_quittance_sequence

Atomic per-tenant per-year counter.

  • tenant_id UUID NOT NULL
  • year INT NOT NULL
  • next_value BIGINT NOT NULL DEFAULT 1
  • PK (tenant_id, year)

webhook_event

  • webhook_event_id UUID PK
  • tenant_id UUID NOT NULL
  • adapter_code TEXT NOT NULL
  • event_id TEXT NOT NULL - provider-supplied
  • transaction_reference TEXT NULL - null until parsed
  • status_in_payload TEXT NULL
  • source_ip INET
  • signature_ok BOOLEAN NOT NULL
  • timestamp_ok BOOLEAN NOT NULL
  • ip_ok BOOLEAN NOT NULL
  • dedup_decision TEXT NOT NULL CHECK IN (NEW, DUP)
  • raw_payload_jsonb JSONB - redacted; nulled out after 2y per R-PAY-122
  • received_at TIMESTAMPTZ NOT NULL
  • UNIQUE (tenant_id, adapter_code, event_id)

idempotency_key

  • tenant_id UUID NOT NULL
  • idempotency_key UUID NOT NULL
  • payment_attempt_id UUID NOT NULL FK
  • expires_at TIMESTAMPTZ NOT NULL - created_at + 72h
  • PK (tenant_id, idempotency_key)
  • INDEX (expires_at) - daily cleanup

payment_dispute

  • payment_dispute_id UUID PK
  • tenant_id UUID NOT NULL
  • payment_transaction_id UUID NOT NULL FK
  • status TEXT NOT NULL CHECK IN (OPEN, REFUNDED_OFFLINE, DISPUTE_REJECTED)
  • reason_code TEXT NOT NULL CHECK IN (CUSTOMER_DENIES, WRONG_AMOUNT, DUPLICATE_PAID, WRONG_CONTRACT, OTHER)
  • note TEXT NULL
  • opened_at / opened_by_user_id / closed_at / closed_by_user_id

adapter_circuit_state

In-memory primary; persisted snapshot for OP dashboard.

  • adapter_code TEXT NOT NULL
  • tenant_id UUID NULL - null = global state for the adapter; tenant-scoped circuits are V2
  • state TEXT NOT NULL CHECK IN (CLOSED, OPEN, HALF_OPEN)
  • consecutive_failures INT NOT NULL DEFAULT 0
  • last_state_change_at TIMESTAMPTZ NOT NULL
  • last_probe_at TIMESTAMPTZ NULL
  • PK (adapter_code, tenant_id)

B.6 Cross-Module Dependencies

PAY consumes (read-only / event subscriber)

  • TENANT - tenants(tenant_id, country_code, legal_name, commercial_name, agrément, RCCM, address, telephone, email), agencies(agency_id, address, telephone), mentions_legales_versions(version_hash, content_blocks). Read at quittance issuance time + frozen onto quittance.
  • CP / CONTRACT - customer_sessions(customer_session_id, tenant_id, signed_link_state) for cross-session anti-leak; contracts(contract_id, tenant_id, agency_id, contract_reference, branch, insured_name, period_covered, amount_due_minor) for quittance content + amount validation.
  • OP - campaign-validation gate calls GET /pay/tenants/{id}/rail-availability.
  • AUTH - signed-link signature scheme (PAY does NOT verify links itself; CP does, then forwards a session token to PAY).

PAY exposes (HTTP API)

Method + path Caller Purpose
POST /pay/transactions CP Initiate (idempotent)
GET /pay/transactions/{ref}/status CP Status poll
GET /pay/transactions/{ref}/quittances CP, OP List quittances + signed download URLs
POST /pay/transactions/{ref}/return CP (or browser via PSP) Return URL handler - actual handler may live in CP per CP-prd; PAY exposes the status endpoint backing it
POST /pay/transactions/{ref}/cancel PSP via redirect Cancel URL handler
POST /pay/webhooks/{adapter_code} PSP / aggregator Webhook ingestion
GET /pay/tenants/{id}/rail-availability OP, CP Rail availability with scenario code
GET /pay/tenants/{id}/adapters TENANT admin UI List configured adapters
POST /pay/tenants/{id}/adapters TENANT admin UI Configure new adapter (BYO or sub-merchant)
POST /pay/tenants/{id}/adapters/{code}/test TENANT admin UI Healthcheck probe
PATCH /pay/tenants/{id}/rails/{rail} TENANT admin UI Toggle rail
POST /pay/transactions/{ref}/manual-reconcile OP Manual reconcile action (CONFIRM or FAIL with PSP ref)
POST /pay/transactions/{ref}/disputes OP Open dispute
PATCH /pay/transactions/{ref}/disputes/{id} OP Resolve dispute
GET /pay/reports/settlement?tenant_id=...&agency_id=...&date=... OP Daily settlement report (PDF + CSV)

PAY publishes (Spring Modulith domain events)

  • PaymentConfirmed { tenant_id, agency_id, transaction_reference, contract_ids[], amount_total_minor, currency, rail, source, occurred_at }
  • PaymentFailed { tenant_id, transaction_reference, provider_error_code, mapped_reason_fr, source, occurred_at }
  • PaymentExpiredPending { tenant_id, transaction_reference, occurred_at }
  • PaymentCancelledByUser { tenant_id, transaction_reference, occurred_at }
  • PaymentManuallyReconciled { tenant_id, transaction_reference, action ∈ {CONFIRM, FAIL}, psp_back_office_reference, operator_user_id, occurred_at }
  • PaymentDisputeOpened / PaymentDisputeResolved
  • AdapterCircuitOpened / AdapterCircuitClosed

Consumers expected (in-process listeners with persistent outbox)

  • OP - listens to all Payment*Event to update contracts.payment_status and refresh dashboards/lists.
  • NOTIF - listens to PaymentConfirmed to dispatch SMS + WA receipt push (per CP-OQ-04: NOTIF V1 must be reworked to add a transactional cp_payment_receipt template, OR PAY uses a thin direct-dispatch surface against NOTIF primitives - alignment required before V1 launch).
  • AUDIT - listens to all events for trail persistence.
  • CP - does NOT listen to events; CP is request/response. Pending screens poll GET /pay/transactions/{ref}/status.

Part C - Constraints & Boundaries

C.1 Regulatory Compliance

PAY's operating frame is the validated docs/legal/memo-conformite-CI-v2.md (audited LEGAL REVIEW REQUIRED - Section F4 is the directly relevant slice).

Anchored obligations (status VÉRIFIÉ in the legal memo):

  • Positioning: technical service provider, NOT distributor / insurer / intermédiaire. CIMA Reg. n°001-2024 art. 3 pt.17 + art. 2 + art. 5. Money path is customer → PSP → broker direct (§A.1). The platform NEVER pools or escrows customer money - settlement Option C (Hybrid) HARD-FORBIDS pooled aggregator settlement (R-PAY-002).
  • Chain of responsibility: CIMA art. 44 + BCEAO Instruction 008-05-2015 art. 18 - the broker (donneur d'ordre) is responsible vis-à-vis the customer. PAY surfaces tenant-side controls (manual reconcile, marquer contesté) so the broker remains in the loop on every payment.
  • Information mandatoire portée par la quittance: CIMA art. 26 (digital information surface) - broker identity, contract identity, payment block, mentions légales - all four blocks REQUIRED on every quittance (PAY-US-10 + A.4).
  • Rétention: CIMA art. 37 + BCEAO arts. 7+26 - 10 years on transactions, quittances, webhook headers, MinIO PDFs (PAY-NFR-20).
  • Comptes marchands agréés uniquement: BCEAO Instruction 008-05-2015 art. 8 - Option A and Option B both target broker merchant accounts; Option B's sub-merchant is registered via the aggregator's KYC (PAY-OQ-06).
  • Force probante des messages électroniques: Loi 2013-546 art. 6 - webhook events + audit trail constitute the evidence chain for customer_payment_* events.

Items requiring legal validation before V1 go-live (status INCERTAIN - avocat requis):

  • D14 PSP T&Cs not pinned per-adapter - PAY-OQ-02. Must be obtained per adapter before activation.
  • D15 Idempotency contractual basis on PSP side - PAY-OQ-03. Implementation is defensive; clause needed per adapter.
  • E1 Magic-link civil opposability in CI - PAY-OQ-05. Affects evidence value of payment-initiation chain.
  • E2 Qualified e-signature for substantial contract modification - PAY-OQ-04. V1 is scoped to renewal-as-is; no PAY flow modifies coverage or premium.
  • Period transitoire CIMA n°001-2024 expired 01/01/2026 - PAY-OQ-08. Affects positioning posture.
  • Aggregator sub-merchant KYC delegation - PAY-OQ-06. Per-aggregator confirmation before Option B activation.

Bloquant ARTCI (separate from PAY but blocks the upstream chain): - B5 - autorisation préalable ARTCI pour traitement des numéros de téléphone (blocks ingestion + by extension every PAY flow that depends on a contact channel). - B10 - autorisation transfert hors-CI vers Meta/WhatsApp (blocks NOTIF receipt push; PAY remains operational SMS-only if WA path is blocked).

C.2 Localization

  • Primary language: French (CI register).
  • Currency: XOF stored as integer minor units; displayed without decimals; thousands separator = space (« 150 000 XOF »).
  • Amounts in quittance "spelled out" use CI number-to-words (« cent cinquante mille francs CFA »).
  • Date format: DD/MM/YYYY for display; ISO 8601 in payloads.
  • Phone display: +225 prefix; storage as E.164.
  • Timezone: Africa/Abidjan (UTC+0).

C.3 Security

  • Tenant isolation: every PAY query is scoped by tenant_id; cross-tenant transaction lookups return 404 (never 403). All tables carry tenant_id.
  • Tenant-aware datasource resolver from day one - supports both V1 DB profiles (shared DB / shared schema, BYO-DB) without code change in PAY (PAY-NFR-18).
  • Adapter credentials at rest: AES-256-GCM envelope encryption; DEK wrapped by KEK held outside the DB (env / KMS); plaintext NEVER logged. Rotation UI in TENANT, audit on every rotation. V2 migrates to external secrets manager (Vault / AWS SM).
  • Webhook signature: HMAC-SHA256 minimum per-tenant secret; ±5 min timestamp window; IP allowlist where adapter publishes one; dedup on event_id.
  • Cross-session anti-leak: every CP-side request validates session ownership before answering (R-PAY-031); 404 on mismatch + audit + anomaly.
  • Customer session token (issued by AUTH from the signed link) is the only credential CP forwards to PAY for customer-initiated calls.
  • Operator authentication: Keycloak (OP module); RBAC roles payment_view, payment_reconcile, payment_dispute, payment_admin. Manual reconcile + dispute actions require their respective role + are audited with operator_user_id.
  • Payment-initiating endpoints honor idempotency key (CLAUDE.md critical rule #8 + R-PAY-020).
  • No service worker / IndexedDB on customer side for any PAY-related state (CLAUDE.md critical rule #11).
  • TLS in transit end-to-end; webhook URLs HTTPS-only; signed URLs HTTPS-only.
  • PII in logs: phone numbers and customer names redacted; transaction references and amounts allowed.

C.4 Connectivity (V1 PRD §30)

  • Customer side: PAY's CP-facing endpoints (init, status, quittances) honor CP's < 200 KB initial payload, p95 ≤ 1.5 s on Slow 3G (PAY-NFR-01), explicit timeouts (PAY-NFR-02), idempotency window matching link TTL (PAY-NFR-06), no-rail graceful degradation with click-to-call fallback (R-PAY-072).
  • Resumability: a customer who loses connection mid-redirect can reopen the same signed link any time within 72 h, land on whichever screen matches their transaction's current status (CP-driven), and the idempotency key honors a re-tap of "Payer" without double-charge.
  • Reconciliation independent of customer return: scheduled poll job converges every transaction within 24 h regardless of whether the customer ever comes back; manual reconcile covers the 0.x % long tail.
  • Adapter outage handling: per-adapter circuit breaker + per-rail routing keep at least one rail live whenever ANY adapter for that rail is healthy.
  • Operator side: PAY-facing OP screens are online-first; manual reconcile + mark-contested + settlement report all require live network (acceptable per V1 PRD §20: "final confirmation of a payment" is explicitly NOT supported offline).

C.5 Out of Scope (V1)

  • Refund orchestration via adapter API (V2).
  • Partial-amount payment per contract (V2).
  • Direct card processor without an aggregator (V2).
  • Pooled-settlement adapters of any kind (forbidden - R-PAY-002).
  • Embedded checkout / non-redirect PSP flows (V3).
  • Bank transfer / cheque / cash as PAY-managed rails (out of platform; cabinet handles offline).
  • USSD payment (V1 PRD §30.4 explicit non-objective).
  • Auto-split high-value transactions across multiple PSP calls (V2 research).
  • Multi-currency (XAF, etc.) - V2 via country-profile.
  • Tax info captured at customer payment time (CM, GA) - V2 via country-profile.
  • Group / consolidated multi-tenant payment views (V2/V3).
  • PSP marketplace / partner-facing API (V3).
  • ERP / accounting system push (V2 connectors).
  • ML-based fraud scoring (V3).
  • Auto-failover between adapters within a single transaction (V2 - V1 only does inter-attempt failover via routing).

C.6 Module-level Done Criteria for V1

A V1 release of PAY is shippable when ALL of the below are demonstrably true in pilot:

  • ≥ 1 active adapter configured + at least the Orange Money + Card rails covered, OR ≥ 1 aggregator (Flutterwave or CinetPay) covering 3 of 4 rails.
  • A new tenant can configure either an Option A direct adapter (paste keys + test connection success) OR an Option B sub-merchant (provision + payout target validated) end-to-end via the TENANT settings UI.
  • A customer can pay one or N contracts, receive a CONFIRMED reçu screen (CP), and download N broker-branded quittances (per-contract) + 1 master receipt.
  • A customer can open the link, lose connection during PSP redirect, reopen 24 h later within link TTL, and land on the correct screen for their transaction's current state - with NO double-charge possible.
  • A webhook lost in transit is recovered by the scheduled poll within 30 min.
  • An EXPIRED_PENDING transaction is reconcilable by an operator with the PSP back-office reference, with full audit trail; downstream effects (quittance, NOTIF, file PAID) fire as if the webhook had arrived.
  • A no-rail tenant is BLOCKED at OP campaign-validation; pre-issued links degrade gracefully with click-to-call.
  • An adapter outage triggers circuit OPEN within 5 consecutive failures, removes the rail from the picker, surfaces in the OP adapter health dashboard, and auto-recovers when probes succeed.
  • The daily settlement report reconciles against the cabinet's PSP back-office for a pilot day with ≥ 50 transactions across rails.
  • Every event in B.3 produces an AUDIT entry per V1 PRD §18 field set.
  • Slow-3G smoke test passes for: rail picker visibility, payment initiation, return-URL routing, quittance download (CLAUDE.md critical rule #12).

End of payment-prd.md.