Payment (PAY) - PRD + FSD V1¶
Module prefix:
PAYPlane: 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 tenantcountry_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_idprovider). Réconciliation : webhook primaire + polling fallback. Échec webhook -> réconciliation manuelle (audit logged). Country-profile lookup depuis tenantcountry_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:
-
Replace informal collections with official merchant channels while keeping the platform OUT of the funds path. Funds flow
customer → PSP → broker merchant accountdirectly. 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. -
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.
-
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). -
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.
-
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_configuredwithtenant_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émentif any) and a payout target (mobile_money_numberfor an OM business wallet ORibanfor 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_idis stored on the tenant adapter row; AUDIT receivestenant_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 apayment_attemptrow in statusCREATED, (b) resolves the active adapter for the rail via routing table, (c) calls the adapter'sinitAPI, (d) on success transitions toPENDINGwithtransaction_reference + redirect_url, (e) returns{ transaction_reference, redirect_url }to CP within 10 s. - Given CP retries
POST /pay/transactionswith the SAMEidempotency_keywithin 72 h, when PAY receives it, then it returns the EXISTING transaction's current{ transaction_reference, redirect_url, status }- no second PSP call, no secondpayment_attemptrow. - 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_FAILEDwithprovider_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}/statuswith 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 emitscustomer_payment_status_polledwithtransaction_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_attemptwith 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}withX-SignatureandX-Timestamp, when PAY receives it, then it (a) verifies the HMAC against the per-tenantwebhook_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 againstwebhook_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 thepayment_transactionsrow, 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_failedwithadapter_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_idarrives twice (adapter retry after a slow ack), when the dedup check fires, then PAY returns 200 immediately, persists no duplicate, auditswebhook_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
PENDINGfor 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
PENDINGfor more than 24 h despite polling, when the next scheduler tick observes it, then PAY transitions it to terminalEXPIRED_PENDING, emitsPaymentExpiredPending, 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_PENDINGpayment 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 terminalCONFIRMEDorFAILED; PAY emitsPaymentManuallyReconciled(which downstream listeners treat exactly like a webhook-driven event - quittance generation, NOTIF dispatch, OP file-status update); AUDIT receivespayment_manually_reconciledwithoperator_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
CONFIRMEDpayment 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 adisputelinked to the payment with statusOPEN; AUDIT receivespayment_dispute_opened. - Given an operator resolves a dispute, when they choose
REFUNDED_OFFLINEorDISPUTE_REJECTED, then the dispute closes, the payment row carriesfinal_outcomereflecting the result, the contractpayment_statusis updated accordingly (refunded → file returns to UNPAID); AUDIT receivespayment_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
PaymentConfirmedfires, 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 underquittances/<tenant_id>/<YYYY>/<quittance_number>.pdf; persists aquittancesrow 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, emitsPaymentCancelledByUser, 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 receivescustomer_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 inEXPIRED_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-availabilityon 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 emitscustomer_payment_blocked_no_railwithreason_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_attemptis in statusCREATED(PSP never called) AND created more than 1 h ago, when the cleanup scheduler fires (every 5 min), then it transitions the attempt to terminalTIMED_OUT_PRE_PSP, frees the customer session'sinflight_transaction_idslot, emitspayment_attempt_timed_out_pre_pspfor 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_eventsraw 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 contractpayment_statusback 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_PENDING24h 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_MERCHANTorSUB_MERCHANT_DIRECT_SETTLEMENTis 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_adapterrow 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_railrow links a tenant + rail and carriesenabled: bool. - R-PAY-040 - Per-rail routing: when CP requests a transaction on rail
Rfor tenantT, 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 declaresRinsupported_rails, (e) the adapter'scountry_codesinclude the tenant's country. Ties break byadapter.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/transactionsacceptsidempotency_key(UUIDv4). If an existingpayment_attemptrow 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_keystable 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_transactionis unique by(tenant_id, transaction_reference); carries the canonical state. Apayment_attemptis 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) createspayment_attempt(status=CREATED), (c) calls adapter init with idempotency_key forwarded in adapter-protocol form, (d) on success transitions to PENDING withtransaction_reference + redirect_url + provider, (e) on failure transitions to terminalINITIATION_FAILEDwithprovider_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_PENDINGand stop polling. - R-PAY-061 - Per-adapter cadence override allowed via config (some adapters resolve slower).
- R-PAY-062 - Manual reconcile by operator emits
PaymentManuallyReconciledwhich downstream listeners treat exactly likePaymentConfirmed/PaymentFailed(no special-casing).
No-rail degradation¶
- R-PAY-070 -
GET /pay/tenants/{id}/rail-availabilityreturns one entry per rail withavailable + 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
availablerails ⇒ « Valider l'envoi » disabled with scenario banner. - R-PAY-072 - CP runtime degradation: zero
availablerails ⇒ 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_urlhandler transitions the attempt toCANCELLED_BY_USER(terminal); redirects to CP summary; emits event. - R-PAY-091 -
return_urlhandler 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; persistsquittancesrows. - 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_transactionrow; 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 toTIMED_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_payloadolder 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_idUUID PKtenant_idUUID NOT NULL (tenant scope)adapter_codeTEXT NOT NULL - references global adapter catalogsettlement_modeTEXT NOT NULL CHECK IN (BYO_MERCHANT,SUB_MERCHANT_DIRECT_SETTLEMENT)merchant_id_or_subaccount_idTEXT NOT NULL - public id; never secretencrypted_secretBYTEA - AES-256-GCM, present only forBYO_MERCHANTencrypted_webhook_secretBYTEA NOT NULLdek_wrapped_by_kek_idTEXT - KEK identifierpayout_target_kindTEXT -MOBILE_MONEY_NUMBER|IBAN, only forSUB_MERCHANT_DIRECT_SETTLEMENTpayout_target_valueTEXT - number or IBANpriorityINT DEFAULT 100 - used as tie-breaker in routingcreated_at/created_by/last_rotated_at/last_rotated_by- UNIQUE (
tenant_id,adapter_code)
tenant_payment_rail¶
tenant_idUUID NOT NULLrailTEXT NOT NULL CHECK IN (ORANGE_MONEY,WAVE,MOMO,CARD)enabledBOOLEAN NOT NULL DEFAULT TRUElast_changed_at/last_changed_by- PK (
tenant_id,rail)
payment_attempt¶
payment_attempt_idUUID PKtenant_idUUID NOT NULLagency_idUUID NOT NULL - derived from contract.agency_idcustomer_session_idUUID NOT NULL - references customer_sessionscontract_idsUUID[] NOT NULL - frozen at attempt creationamount_total_minorBIGINT NOT NULL - sum of contract amounts at attempt creationcurrencyTEXT NOT NULL DEFAULT 'XOF'railTEXT NOT NULLresolved_adapter_codeTEXT NOT NULL - adapter picked at routing timeidempotency_keyUUID NOT NULLstatusTEXT NOT NULL - see B.2 state machine valuestransaction_referenceTEXT - present from PENDING onwardsredirect_urlTEXT - present from PENDING onwardsprovider_error_codeTEXT NULLmapped_reason_frTEXT NULLcreated_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_idUUID PK (= matches the most recent attempt'spayment_attempt_idfor V1; V2 may diverge)tenant_idUUID NOT NULLagency_idUUID NOT NULLtransaction_referenceTEXT NOT NULL - provider-assigned reference (canonical for cross-system lookups)adapter_codeTEXT NOT NULLrailTEXT NOT NULLamount_total_minorBIGINT NOT NULLcurrencyTEXT NOT NULL DEFAULT 'XOF'statusTEXT NOT NULL - mirrors attempt state machine (terminal states only post-resolution)final_outcomeTEXT NULL -null|REFUNDED_OFFLINE|DISPUTE_REJECTED(post-dispute resolution)psp_back_office_referenceTEXT NULL - populated on manual_reconcilelast_webhook_event_idTEXT NULLcreated_at/confirmed_at/last_state_change_atstable_receipt_url_tokenTEXT - 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_idUUID PKtenant_idUUID NOT NULLagency_idUUID NOT NULLpayment_transaction_idUUID NOT NULL FKcontract_idUUID NULL - null on the master receipt (RECAP-)quittance_kindTEXT NOT NULL CHECK IN (PER_CONTRACT,MASTER_RECAP)quittance_numberTEXT NOT NULL -<TENANT>-<YYYY>-<NNNNNN>or<TENANT>-<YYYY>-RECAP-<NNNNNN>mentions_legales_version_hashTEXT NOT NULLminio_object_keyTEXT NOT NULLgenerated_atTIMESTAMPTZ 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 viacontract_id IS NULLpartial unique
tenant_quittance_sequence¶
Atomic per-tenant per-year counter.
tenant_idUUID NOT NULLyearINT NOT NULLnext_valueBIGINT NOT NULL DEFAULT 1- PK (
tenant_id,year)
webhook_event¶
webhook_event_idUUID PKtenant_idUUID NOT NULLadapter_codeTEXT NOT NULLevent_idTEXT NOT NULL - provider-suppliedtransaction_referenceTEXT NULL - null until parsedstatus_in_payloadTEXT NULLsource_ipINETsignature_okBOOLEAN NOT NULLtimestamp_okBOOLEAN NOT NULLip_okBOOLEAN NOT NULLdedup_decisionTEXT NOT NULL CHECK IN (NEW,DUP)raw_payload_jsonbJSONB - redacted; nulled out after 2y per R-PAY-122received_atTIMESTAMPTZ NOT NULL- UNIQUE (
tenant_id,adapter_code,event_id)
idempotency_key¶
tenant_idUUID NOT NULLidempotency_keyUUID NOT NULLpayment_attempt_idUUID NOT NULL FKexpires_atTIMESTAMPTZ NOT NULL - created_at + 72h- PK (
tenant_id,idempotency_key) - INDEX (
expires_at) - daily cleanup
payment_dispute¶
payment_dispute_idUUID PKtenant_idUUID NOT NULLpayment_transaction_idUUID NOT NULL FKstatusTEXT NOT NULL CHECK IN (OPEN,REFUNDED_OFFLINE,DISPUTE_REJECTED)reason_codeTEXT NOT NULL CHECK IN (CUSTOMER_DENIES,WRONG_AMOUNT,DUPLICATE_PAID,WRONG_CONTRACT,OTHER)noteTEXT NULLopened_at/opened_by_user_id/closed_at/closed_by_user_id
adapter_circuit_state¶
In-memory primary; persisted snapshot for OP dashboard.
adapter_codeTEXT NOT NULLtenant_idUUID NULL - null = global state for the adapter; tenant-scoped circuits are V2stateTEXT NOT NULL CHECK IN (CLOSED,OPEN,HALF_OPEN)consecutive_failuresINT NOT NULL DEFAULT 0last_state_change_atTIMESTAMPTZ NOT NULLlast_probe_atTIMESTAMPTZ 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/PaymentDisputeResolvedAdapterCircuitOpened/AdapterCircuitClosed
Consumers expected (in-process listeners with persistent outbox)¶
- OP - listens to all
Payment*Eventto updatecontracts.payment_statusand refresh dashboards/lists. - NOTIF - listens to
PaymentConfirmedto dispatch SMS + WA receipt push (per CP-OQ-04: NOTIF V1 must be reworked to add a transactionalcp_payment_receipttemplate, 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:
+225prefix; 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 carrytenant_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 withoperator_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.