ADR-0010 — Exécution des payouts et gouvernance de la trésorerie Groupe¶
Date : 2026-06-10 (amendée 2026-06-21)
Catégorie : B (engage la mécanique de mouvement de fonds réels — proposée avec délai d'objection CTO de 5 jours ; les arbitrages purement réglementaires/bancaires restent catégorie C)
Décideur : Lead architecte DYNORS (proposition) — CTO (arbitrage des seuils, partenaires bancaires, valeurs de policy Groupe)
Statut : Acceptée (avec amendements 2026-06-21 — voir §Amendements)
Sprint d'implémentation : à planifier — débloque la « récupération de l'argent », aujourd'hui non implémentée (TODO Phase 3 confirmé dans SettlementService:141)
Contexte¶
DYNORS PAIEMENT modélise déjà la structure financière du Groupe via PaymentSettlementAccount en 4 couches :
GROUPE— compte opérations DYNORS, reçoit toutes les commissions.SATELLITE— compte du tenant satellite, destination des reversements.ESCROW— transit / séquestre (ELISA livraison, marketplace).INFRA— dépenses cloud/infra DYNORS.
Le numéro de compte n'est jamais en clair : vaultRef opaque. SettlementService calcule un run J-1 par tenant, applique une commission, contrôle un seuil minimum, et attend une validation admin avant virement (PAIEMENT_SUPER_ADMIN / PAIEMENT_ADMIN, audit INSERT-ONLY 7 ans BCEAO). C'est une base saine.
Mais l'exécution réelle du virement n'existe pas. SettlementService (ligne ~141) porte un TODO Phase 3 : récupérer les credentials du compte via VaultCredentialsService puis appeler l'API Wave Aggregated ou BCEAO selon le type de compte. Autrement dit : on calcule combien reverser, mais on ne reverse pas. C'est le trou fonctionnel central de la question « comment récupérer l'argent sur les comptes ».
Deux défauts de gouvernance accompagnent ce trou :
- Le taux de commission est une constante de code :
DEFAULT_COMMISSION_RATE = 1.5%en dur dansSettlementService. C'est un paramètre de revenu Groupe, par contrat — il viole l'anti-pattern explicite de la doctrine Groupe (« coder des règles financières comme constantes applicatives »). - La validation des payouts est mono-acteur : un seul
PAIEMENT_SUPER_ADMINdéclenche. Pour des mouvements de fonds, l'absence de règle des deux personnes est un risque de fraude interne et d'erreur.
Décision¶
Construire l'exécution des payouts derrière un port d'exécution bancaire, avec gouvernance maker-checker, credentials Vault dynamiques, taux en policy, et réconciliation obligatoire.
1. Port d'exécution PayoutExecutor (par type de compte)¶
public interface PayoutExecutor {
PayoutRail rail(); // WAVE_AGGREGATED | BCEAO_VIREMENT | OM_DISBURSE | ...
PayoutResult execute(PayoutOrder order, IdempotencyKey key)
throws PayoutRailUnavailableException; // idempotent, jamais de double virement
PayoutStatus poll(PayoutReference ref); // statut asynchrone réconciliable
PayoutResult reverse(PayoutReference ref, String reason, ApproverPair approvers)
throws PayoutNotReversibleException; // contre-écriture, maker-checker renforcé
}
Résolution au runtime par providerType du PaymentSettlementAccount, même patron que TaxAuthorityAdapter (ADR-0001) et PspRouter. Chaque rail a son circuit breaker indépendant.
Clé d'idempotence (amendement 2026-06-21) : IdempotencyKey est déterministe = sha256(tenantId + runDate + destinationAccountId + netAmountMinor + currency + groupPolicyVersion). Re-rejouer un batch ne crée jamais de second virement. Le rail bancaire reçoit cette clé en header (Wave : X-Idempotency-Key ; BCEAO : référence de virement unique).
Cycle de vie du payout (amendement 2026-06-21) : PENDING → APPROVED → SENT → SETTLED (succès) ou → REVIEW → REVERSED | FAILED. Aucun saut autorisé. Persisté dans payment_settlement_run.payout_state avec horodatage par transition.
Réconciliation poll (amendement 2026-06-21) : tâche @Scheduled(fixedDelay = 5min) sur les payouts SENT non encore SETTLED ; SLA cible 60 min ; au-delà, état REVIEW + alerte SRE Slack #ops-paiement. Webhook PSP accepté en complément (jamais en remplacement, le poll reste la source de vérité — webhook spoofable).
2. Maker-checker obligatoire sur les payouts au-dessus d'un seuil¶
- En dessous du seuil (paramétrable, fourni par
FinancialPolicyProvider— voir §7) : validation simplePAIEMENT_ADMIN. - Au-dessus du seuil : deux acteurs distincts requis — un initiateur (
PAIEMENT_ADMIN) et un approbateur (PAIEMENT_SUPER_ADMIN), jamais la même identité. Refus si initiateur == approbateur. - Tout payout est tracé dans l'audit INSERT-ONLY existant, avec les deux identités.
Durcissements techniques (amendement 2026-06-21) :
- Comparaison sur le
subJWT (pasusername, pas IP) ; vérification que les deux JWT sont issus pardynors-auth(claimiss) et non par un autre realm. - Délai max d'approbation : 24 h après création de la demande (
approvalDeadlineHoursdansGroupFinancialPolicy). Au-delà, la demande expire →FAILEDavec motifAPPROVAL_EXPIRED. Pas de rubber-stamp d'une demande oubliée. - Séparation des rôles : un même
subne peut pas porter à la foisPAIEMENT_ADMINetPAIEMENT_SUPER_ADMIN(contrôle à l'attribution dansdynors-entitlements— règle dure, refus dès le grant). - Actions sensibles hors payout soumises au même maker-checker, quel que soit le montant :
- changement du
destinationAccountIdd'un tenant, - modification de la
GroupFinancialPolicy(récursivité saine : la policy qui régit le maker-checker est elle-même protégée par maker-checker), - modification d'un
vaultRefde compteGROUPE/SATELLITE/ESCROW. - Quarantaine post-changement de destinataire : après modification du
destinationAccountIdd'un tenant, aucun payout autorisé pendantdestinationChangeQuarantineHoursh (défaut Groupe : 24 h). Notification email/SMS au tenant viadynors-notify. Mitigation BEC (Business Email Compromise).
3. Credentials bancaires — stratégie par rail (amendement 2026-06-21)¶
L'as-is VaultCredentialsService est en mode statique (store/read/rotate/delete), aujourd'hui en stub (dynors.vault.stub=true). Le terme « Vault dynamique » est imprécis car peu de rails offrent un moteur Vault dynamic. Stratégie par rail :
| Rail | Mode credentials | Justification |
|---|---|---|
WAVE_AGGREGATED, OM_DISBURSE, PAYTECH, INTOUCH |
Statique, scope minimal, rotation 90 j forcée | Pas de Vault dynamic engine officiel ; mitigation = clé API dédiée par sous-compte (jamais une clé master), rotation calendaire, alerte si âge > 80 j. |
BCEAO_VIREMENT (banque relationnelle) |
Dynamique si moteur Vault DB/API disponible avec la banque (rare), sinon statique scope-minimal | Possible avec banques modernes (API banking) ; sinon retombe sur statique avec rotation. |
INTERNAL (transferts entre comptes DYNORS internes) |
Pas de credentials externes | Comptabilité interne, pas d'API bancaire. |
- Les credentials d'un compte ne sont jamais en base ni en variable d'env longue durée.
vaultRef→ secret Vault, opaque. - Lease bancaire à la demande quand le rail le supporte : crédential émis au moment du
execute(), révoqué aprèsSETTLEDouFAILED. Sinon : rotation calendaire 90 j + révocation immédiate via playbook. - Un playbook de rotation d'urgence (déjà évoqué dans la sécu paiement) couvre la révocation immédiate sur compromission suspectée.
- Le mode
dynors.vault.stub=trueest interdit horslocaletdev— Gate SIRRAT 1 refuse une promotion versint/rmoa/prodsi le stub est actif.
4. Taux et règles de reversement en policy, pas en code¶
- Supprimer
DEFAULT_COMMISSION_RATE = "0.0150"deSettlementService.java:40; supprimer le fallbackpolicy.getCommissionRate() != null ? ... : DEFAULT_COMMISSION_RATEde la ligne 93. - Trois niveaux de paramètres financiers, gouvernés via le
FinancialPolicyProvider(§7) : - Niveau A — contractuel par tenant : taux commission, mode (DAILY/WEEKLY/MANUAL/ESCROW), seuil minimum, compte destinataire →
PaymentSettlementPolicy(table existante, à étendre). - Niveau B — gouvernance Groupe : défaut commission (si pas de policy tenant), seuil maker-checker, délai d'approbation, quarantaine destinataire, SLA réconciliation, paramètres retry/circuit breaker → nouvelle table
payment_group_policy_version(versionnée, INSERT-ONLY, voir §7). - Niveau C — opérationnel : timeouts HTTP par rail, sample log →
application.ymlinjecté Forge/Vault (cf. ADR-0009). - FAIL CLOSED si lookup impossible : si le provider ne trouve ni policy tenant ni policy Groupe publiée valide, le settlement est refusé (état
FAILEDavec motifPOLICY_UNAVAILABLE), jamais d'application d'un défaut hardcodé. Mieux vaut un payout bloqué qu'un mauvais prélèvement.
5. Réconciliation obligatoire¶
- Tout payout exécuté doit être réconcilié contre le statut réel du rail (
poll) avant d'être marquéSETTLED. Un payoutSENTnon confirmé sous SLA bascule enREVIEW, jamais en succès silencieux. - Source de vérité =
poll(pull). Le webhook PSP est complémentaire et jamais seul (spoofable, peut manquer, ne dispense pas du poll).
5bis. Événements d'intégration (amendement 2026-06-21)¶
Chaque transition d'état publie un événement sur dynors-events (corrélation request_id + tenant via EventCorrelation), consommé par :
| Événement | Topic | Consommateurs | Effet |
|---|---|---|---|
paiement.payout.approved.v1 |
audit.paiement |
audit WORM | trace probante 10 ans |
paiement.payout.sent.v1 |
paiement.payout |
BOOKS | écriture comptable provisionnelle |
paiement.payout.settled.v1 |
paiement.payout |
BOOKS, FISCAL, JARAAF (si filiale) | écriture définitive ; facture interne inter-compagnies pour la commission Groupe ↔ filiale ; paie si payout = salaire bulk |
paiement.payout.reversed.v1 |
paiement.payout |
BOOKS, FISCAL | contre-écriture, avoir de la facture commission |
paiement.group_policy.published.v1 |
paiement.config |
tous nœuds PAIEMENT (cache invalidation) ; audit | rechargement provider |
paiement.destination_account.changed.v1 |
paiement.security |
dynors-notify (alerte tenant), audit |
mitigation BEC |
5ter. Multi-devises et reversal (amendement 2026-06-21)¶
- v1 = XOF uniquement (centimes minor units). Les comptes
payment_settlement_accountsportent déjà implicitement la devise via leurproviderType(Wave SN = XOF, BCEAO = XOF, OM = XOF). - Filiale étrangère ou client multi-pays → ADR ultérieure dédiée au multi-devises (taux de change, source du taux, arrondi BCEAO/IFRS, exposition de change). Pas avant l'ouverture d'un premier marché hors UEMOA.
- Reversal : opération
reverse(reference, reason, approvers)sur le port. Maker-checker renforcé (deuxPAIEMENT_SUPER_ADMINdistincts, peu importe le montant, avec motif obligatoire ≥ 30 caractères). Si le rail ne supporte pas le reverse natif → procédure manuelle hors système avec contre-écriture comptable BOOKS et audit dédié.
Frontière app / Groupe (rappel doctrine)¶
Les apps satellites ne voient jamais les comptes GROUPE/ESCROW/INFRA ni les rails bancaires. Elles encaissent via /paiement/** et reçoivent leurs reversements sur leur compte SATELLITE. Toute la trésorerie Groupe (commissions, escrow, infra) est portée par DYNORS PAIEMENT + DYNORS Groupe, conformément à la doctrine.
7. FinancialPolicyProvider — provider de gouvernance financière (amendement 2026-06-21)¶
C'est le mécanisme qui sort toutes les valeurs financières du code et les place sous gouvernance auditée. Réflexion en profondeur sur où ces paramètres doivent vivre et qui les sert.
7.1 Le problème, formulé proprement¶
On a trois familles de paramètres qui n'ont pas la même nature et ne doivent pas vivre au même endroit :
| Famille | Exemple | Cycle de changement | Gouvernance | Critique |
|---|---|---|---|---|
| A. Contractuel par tenant | commissionRate=0.0120 pour le tenant supergest-sn-abc |
À la signature/renégociation contrat | Commercial + finance | OUI (argent) |
| B. Gouvernance Groupe | defaultCommissionRate=0.0150, makerCheckerThresholdMinor=10_000_000, approvalDeadlineHours=24 |
Rare (trimestriel) | CTO + Comité Groupe | OUI (argent + sécurité) |
| C. Opérationnel | wave.timeout.ms=5000, log.level=INFO, circuit.breaker.failure.rate.threshold=50 |
Souvent (tuning) | SRE | NON |
Anti-pattern à bannir : tout fourrer au même endroit (constantes Java, ou tout en YAML, ou tout en DB) ignore les écarts de cycle, de gouvernance et de criticité.
7.2 Choix d'emplacement par famille¶
| Famille | Emplacement | Mécanisme de fourniture | Refus de la voie facile |
|---|---|---|---|
| A | DB payment_settlement_policies (existante) — une ligne par tenant, modifiable via admin avec audit |
Lecture par FinancialPolicyProvider.forTenant(tenantId) avec cache Caffeine 60 s |
YAML rejeté : pas par-tenant ; constante rejetée : pas modifiable à chaud ; Vault rejeté : pas le bon outil pour de la donnée métier auditée |
| B | DB payment_group_policy_version (nouvelle, INSERT-ONLY versionnée) — chaque modif = nouvelle ligne datée, signée maker+checker |
Lecture par FinancialPolicyProvider.currentGroupPolicy() (résolution version active à date courante) avec cache 60 s |
YAML/Forge rejetés : pas de maker-checker possible, pas d'audit signé, changement = release ; Vault rejeté : pas conçu pour versioning métier ; constante = état actuel, défaut éliminatoire |
| C | application.yml du repo dynors-paiement (par cercle, valeurs injectées Forge ADR-0009) |
Spring @Value / @ConfigurationProperties classique |
DB rejeté : surdimensionné pour du tuning SRE ; Vault uniquement pour les secrets, pas pour des seuils non-secrets |
Critère discriminant : « cette valeur a-t-elle un impact financier direct ? » → DB versionnée (A ou B) ; sinon → YAML.
7.3 Schéma de la table payment_group_policy_version¶
CREATE TABLE payment_group_policy_version (
id varchar(36) PRIMARY KEY,
version_number int NOT NULL UNIQUE, -- monotone, jamais réutilisé
valid_from timestamptz NOT NULL, -- inclusif
valid_until timestamptz NULL, -- NULL = active ; sinon = supplantée par la version suivante
default_commission_rate numeric(6,4) NOT NULL, -- ex. 0.0150
maker_checker_threshold_minor bigint NOT NULL, -- en centimes XOF
approval_deadline_hours int NOT NULL, -- 24 par défaut
destination_change_quarantine_hours int NOT NULL, -- 24 par défaut
reconciliation_sla_minutes int NOT NULL, -- 60 par défaut
payout_retry_max_attempts int NOT NULL, -- 3 par défaut
payout_retry_backoff_ms bigint NOT NULL, -- 60_000 par défaut
-- gouvernance maker-checker SUR la policy elle-même
proposed_by_sub varchar(128) NOT NULL, -- sub JWT du maker
approved_by_sub varchar(128) NOT NULL, -- sub JWT du checker (≠ proposed_by_sub)
reason varchar(2000) NOT NULL,
signature_hash varchar(128) NOT NULL, -- HMAC-SHA256 de (version + champs + 2 subs)
published_at timestamptz NOT NULL,
CONSTRAINT chk_distinct_actors CHECK (proposed_by_sub <> approved_by_sub)
);
Propriétés : INSERT-ONLY, jamais d'UPDATE/DELETE. La version « courante » est celle dont valid_until IS NULL. Publier une nouvelle version = (i) INSERT nouvelle ligne avec valid_until = NULL, (ii) UPDATE de l'ancienne pour poser son valid_until = now(). C'est la seule UPDATE autorisée, exécutée en transaction unique.
7.4 API du provider¶
public interface FinancialPolicyProvider {
/** Niveau A — politique contractuelle du tenant (jamais null ; lève {@link PolicyNotFoundException}). */
TenantSettlementPolicy forTenant(String tenantId);
/** Niveau B — version active de la policy Groupe à l'instant T (jamais null ; lève {@link PolicyNotFoundException}). */
GroupFinancialPolicy currentGroupPolicy();
/** Niveau B — version active à une date passée (audit, rejeu d'un settlement historique avec les valeurs de l'époque). */
GroupFinancialPolicy groupPolicyAt(Instant pointInTime);
/** Publication d'une nouvelle version Groupe — exige deux acteurs distincts. */
GroupFinancialPolicy publishGroupPolicy(GroupFinancialPolicyDraft draft, String proposedBySub, String approvedBySub, String reason);
/** Historique complet (audit). */
List<GroupFinancialPolicyVersion> history(Instant from, Instant to);
}
public record GroupFinancialPolicy(
int versionNumber,
BigDecimal defaultCommissionRate,
long makerCheckerThresholdMinor,
int approvalDeadlineHours,
int destinationChangeQuarantineHours,
int reconciliationSlaMinutes,
int payoutRetryMaxAttempts,
long payoutRetryBackoffMs,
Instant validFrom,
String proposedBySub,
String approvedBySub) {}
Implémentation : JpaFinancialPolicyProvider avec :
- cache local Caffeine TTL 60 s par instance (acceptable car publication = événement rare),
- invalidation event-driven : sur réception de paiement.group_policy.published.v1, chaque nœud invalide son cache,
- horloge serveur comme pointInTime par défaut (pas de paramètre client pour éviter les détournements),
- mesure Prometheus : paiement_policy_cache_hits_total, paiement_policy_lookup_errors_total.
7.5 Utilisation depuis SettlementService¶
// Avant (état actuel) :
BigDecimal rate = policy.getCommissionRate() != null ? policy.getCommissionRate() : DEFAULT_COMMISSION_RATE;
// Après (cible ADR-0010) :
TenantSettlementPolicy tenantPolicy = policyProvider.forTenant(tenantId); // niveau A
if (!tenantPolicy.isActive()) {
return SettlementResult.skipped("Pas de politique active");
}
BigDecimal rate = tenantPolicy.commissionRate()
.orElseGet(() -> policyProvider.currentGroupPolicy().defaultCommissionRate()); // niveau B fallback
// Si forTenant() ou currentGroupPolicy() lève PolicyNotFoundException → FAIL CLOSED (state=FAILED)
Le SettlementService ne contient plus aucune constante financière. Toute relecture de code se résume à « quelle policy a été appliquée à cette ligne de payment_settlement_run ? » → réponse traçable par groupPolicyVersion stocké dans le run (cf. clé d'idempotence §1).
7.6 UI admin et workflow de publication¶
Deux pages dans le portail admin PAIEMENT (rôle dédié PAIEMENT_GROUP_GOVERNANCE, distinct de PAIEMENT_SUPER_ADMIN — séparation des rôles ADR-0009/0011) :
/admin/group-policy/edit— saisie d'un draft, validation locale (valeurs dans bornes raisonnables : taux ∈ [0, 0.10], seuil ≥ 0, délais > 0), bouton « Soumettre pour approbation ». Crée une demande enPENDING_APPROVALnotifiée aux porteurs du rôle./admin/group-policy/approve/{requestId}— approbateur autre que le proposant ; bouton « Approuver et publier » ; refus possible avec motif. À l'approbation, la nouvelle version est insérée, l'ancienne marquéevalid_until=now(), l'événementpaiement.group_policy.published.v1est publié./admin/group-policy/history— toutes les versions, qui a proposé / approuvé, signature_hash vérifiable.
7.7 Cas filiale et gouvernance (lien ADR-0009)¶
- Une filiale obtient sa propre
PaymentSettlementPolicy(niveau A) avec soncommissionRatecontractuel (peut différer du défaut Groupe — c'est précisément l'intérêt du contrat inter-compagnies). - La filiale n'a pas accès à la
GroupFinancialPolicyni au workflow de publication. C'est la doctrine Groupe : la filiale est un tenant, pas un co-gouvernant. - Si la filiale veut faire évoluer son taux : demande commerciale → modification de sa
PaymentSettlementPolicypar unPAIEMENT_ADMINGroupe avec maker-checker (changement de taux contractuel = action sensible).
6. Cas filiale — même mécanique, autre entité légale (lien ADR-0009, axe gouvernance)¶
La filialisation d'une app (ex. DAWALALE, prévue) ne change pas la mécanique : la filiale est un tenant du hub paiement avec son compte SATELLITE — simplement, ce compte appartient à sa propre entité légale, pas à DYNORS. Conséquences :
- La commission prélevée devient un frais de service inter-compagnies, défini par contrat Groupe ↔ filiale (taux dans
PaymentSettlementPolicydu tenant filiale ; valeur = décision catégorie C). - Le maker-checker s'applique à l'identique ; l'approbateur des payouts vers une filiale reste côté DYNORS PAIEMENT (la filiale ne s'auto-approuve pas ses reversements).
- Aucune duplication d'infrastructure : la filiale n'obtient ni accès aux rails bancaires, ni instance paiement dédiée — elle reste consommatrice du hub, conformément à la doctrine (« le produit ne porte pas la banque »).
- Le registre 4 couches couvre déjà ce cas sans modification de schéma : c'est une ligne de plus dans
payment_settlement_accounts, pas une évolution de modèle.
Conséquences¶
Bénéfices¶
- La « récupération de l'argent » devient réelle, idempotente et réconciliée, au lieu d'un TODO.
- Le risque de fraude interne / erreur sur mouvement de fonds est cadré par maker-checker.
- Les credentials bancaires cessent d'être un secret statique à longue durée de vie.
- Le revenu Groupe (commission) devient un paramètre contractuel gouverné, plus une constante de code.
Coûts / contraintes induites¶
- Intégration réelle des rails (Wave Aggregated, BCEAO virement, OM disburse) — dépend de contrats et d'accès API que seul le CTO/Groupe peut acter (catégorie C pour le choix des partenaires).
- Le maker-checker impose au moins deux identités habilitées distinctes en exploitation — contrainte organisationnelle à assumer.
- La réconciliation ajoute un état asynchrone et un SLA à superviser.
- Vault dynamique pour les credentials bancaires demande une maturité Vault (moteurs dynamiques) à valider par rail.
Composants impactés¶
dynors-paiement—SettlementService(retrait du taux en dur, branchementPayoutExecutor), nouveau port + implémentations par rail,AdminSettlementController(maker-checker),PaymentSettlementPolicy(taux/seuil/cadence).- Vault — moteurs de secrets dynamiques par rail bancaire.
- Audit DB INSERT-ONLY — enrichi des deux identités maker/checker.
- Doctrine Groupe — cette ADR matérialise les paliers 3/4 (gouvernance financière, settlement).
Alternatives considérées¶
Alternative A — Garder le TODO et virer manuellement hors système. Écartée : pas de traçabilité, pas d'idempotence, pas de réconciliation, risque d'erreur et de fraude maximal. Inacceptable pour de l'argent.
Alternative B — Exécuter le payout en direct dans SettlementService sans port. Écartée : couple le calcul de settlement aux API bancaires, empêche le test, ne scale pas aux multiples rails. Le port est cohérent avec TaxAuthorityAdapter / PspRouter.
Alternative C — Maker-checker sur tous les payouts sans seuil. Écartée comme défaut : friction opérationnelle inutile sur les micro-reversements. Le seuil (paramétrable) cible le contrôle là où le risque est réel ; le CTO fixe sa valeur.
Alternative D — Taux de commission par configuration globale unique. Écartée : les contrats satellites diffèrent. La policy par tenant existe déjà ; l'utiliser est le bon niveau.
Plan d'exécution¶
Sprint 1 — Gouvernance des paramètres (prérequis dur, rien ne bouge sans ça)¶
- Créer la table
payment_group_policy_version(Liquibase) avec contraintes (acteurs distincts, INSERT-ONLY via trigger). - Implémenter
FinancialPolicyProvider(interface +JpaFinancialPolicyProvider+ cache Caffeine + invalidation event-driven). - Étendre
PaymentSettlementPolicy: ajouter colonnesmaker_checker_threshold_minor_override(NULLABLE — null = défaut Groupe),approval_deadline_hours_override,destination_change_quarantine_hours_override. - Pages admin policy Groupe (édition draft, approbation, historique) + rôle
PAIEMENT_GROUP_GOVERNANCEdansdynors-entitlements. - Seed initial v1 de la
GroupFinancialPolicyavec valeurs proposées (signées par le CTO en cérémonie d'amorçage) — voir §Valeurs initiales proposées. - Retirer
DEFAULT_COMMISSION_RATEdeSettlementService.java:40; basculer la lecture versFinancialPolicyProvider. Si lookup KO →FAILED(POLICY_UNAVAILABLE).
Sprint 2 — Port d'exécution + maker-checker payouts¶
- Définir
PayoutExecutor+PayoutOrder(incluantIdempotencyKeydéterministe) +PayoutResult+PayoutStatus+ registry parproviderType. - Implémenter le maker-checker dans
AdminSettlementController.triggerManualet surupsertPolicy(changement dedestinationAccountIdou de taux) + tablepayment_settlement_approval_request. - Quarantaine post-changement destinataire + notification
dynors-notify. - Événements
paiement.payout.*.v1(5bis) surdynors-events; consumers BOOKS/FISCAL/JARAAF préparés (peuvent être stubs au début).
Sprint 3 — Premier rail réel¶
- Brancher la stratégie credentials par rail (§3) pour le rail pilote — désactiver
dynors.vault.stubenint(Gate SIRRAT 1). - Implémenter le premier
PayoutExecutor(rail à acter CTO) + réconciliationpoll(cron 5 min, SLA 60 min). - Tests bout en bout : settlement → approval → execute → poll → SETTLED + événements + écriture BOOKS.
Sprint 4 — Élargissement¶
- Étendre aux autres rails (Wave Aggregated, OM Disburse, BCEAO virement).
- Implémenter
reverse()pour les rails qui le supportent ; procédure manuelle documentée sinon. - Documenter la trésorerie Groupe complète (encaissement → commission GROUPE → reversement SATELLITE → ESCROW ELISA).
Valeurs initiales proposées pour GroupFinancialPolicy v1 (à valider CTO — catégorie C)¶
| Paramètre | Valeur proposée | Justification |
|---|---|---|
defaultCommissionRate |
PAS DE DÉFAUT EXPLICITE — un tenant sans commissionRate contractuel = FAILED policy |
Refus du prélèvement implicite (mieux qu'un défaut hardcoded historique 1,5 %) |
makerCheckerThresholdMinor |
10 000 000 (= 100 000 XOF) | Seuil bas en démarrage, à relever quand confiance opérationnelle acquise |
approvalDeadlineHours |
24 | Une journée ouvrée |
destinationChangeQuarantineHours |
24 | Une journée avant premier reversement vers nouveau compte |
reconciliationSlaMinutes |
60 | 1 h, alerte SRE au-delà |
payoutRetryMaxAttempts |
3 | Standard |
payoutRetryBackoffMs |
60 000 (1 min) | Évite martèlement du rail |
Remarque sur le défaut commission : on supprime le filet historique 1,5 %. Si un tenant n'a pas de
commissionRatecontractuel signé, son settlement échoue avecPOLICY_UNAVAILABLE— c'est une régression apparente vs l'état actuel, mais c'est plus sûr : aucun prélèvement n'est appliqué sans contrat explicite. Tous les tenants actifs doivent avoir leurcommissionRatesaisi avant la bascule (migration data à acter en pré-sprint 1).
Suivi¶
Critère de succès : un settlement run produit un virement réel idempotent, validé par deux acteurs distincts au-dessus du seuil, réconcilié contre le statut du rail, sans aucun secret bancaire statique ni taux en dur.
Signal de régression : tout payout exécutable par une identité unique au-dessus du seuil, tout credential bancaire statique, ou toute règle financière recodée en constante.
Décision réservée CTO (catégorie C) : choix des partenaires/rails bancaires, valeurs de seuil et de commission, ouverture d'un compte par pays.
Amendements¶
| Date | Amendement | Origine |
|---|---|---|
| 2026-06-21 | Idempotence : IdempotencyKey = sha256(tenant+date+dest+amount+currency+policyVersion). |
Revue critique avant acceptation. |
| 2026-06-21 | Cycle de vie payout : PENDING → APPROVED → SENT → SETTLED \| REVIEW → REVERSED \| FAILED. |
Revue critique. |
| 2026-06-21 | Réconciliation poll chiffrée : cron 5 min, SLA 60 min, source de vérité ≠ webhook. |
Revue critique. |
| 2026-06-21 | Maker-checker durci : comparaison sur sub JWT, délai 24 h, séparation rôles, actions sensibles hors payout, quarantaine destinataire. |
Revue critique. |
| 2026-06-21 | Credentials : stratégie par rail (statique scope-minimal rotation 90 j vs dynamique selon support) ; stub interdit hors local/dev. | Revue critique. |
| 2026-06-21 | FinancialPolicyProvider + table versionnée payment_group_policy_version + suppression de DEFAULT_COMMISSION_RATE ; fail-closed si lookup KO. |
Revue critique. |
| 2026-06-21 | Événements d'intégration paiement.payout.*.v1 vers BOOKS/FISCAL/JARAAF + audit WORM. |
Revue critique. |
| 2026-06-21 | Multi-devises = ADR ultérieure ; v1 XOF uniquement. | Revue critique. |
| 2026-06-21 | reverse() ajouté au port avec maker-checker renforcé. |
Revue critique. |
| 2026-06-21 | Valeurs initiales GroupFinancialPolicy v1 proposées (à valider CTO catégorie C). |
Revue critique. |
Références¶
- Modèle de comptes :
dynors-paiement/src/main/java/com/dynors/paiement/persistence/entity/PaymentSettlementAccount.java - Settlement actuel :
dynors-paiement/src/main/java/com/dynors/paiement/settlement/SettlementService.java(lignes 40, 93, 141 — preuves as-is) - Admin actuel :
dynors-paiement/src/main/java/com/dynors/paiement/admin/AdminSettlementController.java(mono-acteur l.200-219) - Vault credentials :
dynors-paiement/src/main/java/com/dynors/paiement/admin/VaultCredentialsService.java(stub par défaut, statique) - Sécurité paiement :
docs/projet-nouveau/DynorsPaiement/DYNORS_PAIEMENT_SYNTHESE_PDF_v1_0.md§6, §7 - Doctrine Groupe (paliers 3/4, trésorerie) :
docs/architecture/DOCTRINE_DYNORS_GROUPE_STRUCTURE_PORTEUSE.md§6 - Patron de port : ADR-0001 (
TaxAuthorityAdapter) - Axe
gouvernance(filiale, client) : ADR-0009 (Acceptée 2026-06-21) - Realm JWT (claim
iss=dynors-auth, séparation rôles) : ADR-0011 - Couloir
lane(pour traçabilité audit des payouts par couloir) : ADR-0017 / ADR-0020