Aller au contenu

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 :

  1. Le taux de commission est une constante de code : DEFAULT_COMMISSION_RATE = 1.5% en dur dans SettlementService. 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 »).
  2. La validation des payouts est mono-acteur : un seul PAIEMENT_SUPER_ADMIN dé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 simple PAIEMENT_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 sub JWT (pas username, pas IP) ; vérification que les deux JWT sont issus par dynors-auth (claim iss) et non par un autre realm.
  • Délai max d'approbation : 24 h après création de la demande (approvalDeadlineHours dans GroupFinancialPolicy). Au-delà, la demande expire → FAILED avec motif APPROVAL_EXPIRED. Pas de rubber-stamp d'une demande oubliée.
  • Séparation des rôles : un même sub ne peut pas porter à la fois PAIEMENT_ADMIN et PAIEMENT_SUPER_ADMIN (contrôle à l'attribution dans dynors-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 destinationAccountId d'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 vaultRef de compte GROUPE/SATELLITE/ESCROW.
  • Quarantaine post-changement de destinataire : après modification du destinationAccountId d'un tenant, aucun payout autorisé pendant destinationChangeQuarantineHours h (défaut Groupe : 24 h). Notification email/SMS au tenant via dynors-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ès SETTLED ou FAILED. 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=true est interdit hors local et dev — Gate SIRRAT 1 refuse une promotion vers int/rmoa/prod si le stub est actif.

4. Taux et règles de reversement en policy, pas en code

  • Supprimer DEFAULT_COMMISSION_RATE = "0.0150" de SettlementService.java:40 ; supprimer le fallback policy.getCommissionRate() != null ? ... : DEFAULT_COMMISSION_RATE de 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.yml injecté 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 FAILED avec motif POLICY_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 payout SENT non confirmé sous SLA bascule en REVIEW, 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_accounts portent déjà implicitement la devise via leur providerType (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é (deux PAIEMENT_SUPER_ADMIN distincts, 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 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 en PENDING_APPROVAL notifié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ée valid_until=now(), l'événement paiement.group_policy.published.v1 est 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 son commissionRate contractuel (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 GroupFinancialPolicy ni 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 PaymentSettlementPolicy par un PAIEMENT_ADMIN Groupe 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 PaymentSettlementPolicy du 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-paiementSettlementService (retrait du taux en dur, branchement PayoutExecutor), 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)

  1. Créer la table payment_group_policy_version (Liquibase) avec contraintes (acteurs distincts, INSERT-ONLY via trigger).
  2. Implémenter FinancialPolicyProvider (interface + JpaFinancialPolicyProvider + cache Caffeine + invalidation event-driven).
  3. Étendre PaymentSettlementPolicy : ajouter colonnes maker_checker_threshold_minor_override (NULLABLE — null = défaut Groupe), approval_deadline_hours_override, destination_change_quarantine_hours_override.
  4. Pages admin policy Groupe (édition draft, approbation, historique) + rôle PAIEMENT_GROUP_GOVERNANCE dans dynors-entitlements.
  5. Seed initial v1 de la GroupFinancialPolicy avec valeurs proposées (signées par le CTO en cérémonie d'amorçage) — voir §Valeurs initiales proposées.
  6. Retirer DEFAULT_COMMISSION_RATE de SettlementService.java:40 ; basculer la lecture vers FinancialPolicyProvider. Si lookup KO → FAILED (POLICY_UNAVAILABLE).

Sprint 2 — Port d'exécution + maker-checker payouts

  1. Définir PayoutExecutor + PayoutOrder (incluant IdempotencyKey déterministe) + PayoutResult + PayoutStatus + registry par providerType.
  2. Implémenter le maker-checker dans AdminSettlementController.triggerManual et sur upsertPolicy (changement de destinationAccountId ou de taux) + table payment_settlement_approval_request.
  3. Quarantaine post-changement destinataire + notification dynors-notify.
  4. Événements paiement.payout.*.v1 (5bis) sur dynors-events ; consumers BOOKS/FISCAL/JARAAF préparés (peuvent être stubs au début).

Sprint 3 — Premier rail réel

  1. Brancher la stratégie credentials par rail (§3) pour le rail pilote — désactiver dynors.vault.stub en int (Gate SIRRAT 1).
  2. Implémenter le premier PayoutExecutor (rail à acter CTO) + réconciliation poll (cron 5 min, SLA 60 min).
  3. Tests bout en bout : settlement → approval → execute → poll → SETTLED + événements + écriture BOOKS.

Sprint 4 — Élargissement

  1. Étendre aux autres rails (Wave Aggregated, OM Disburse, BCEAO virement).
  2. Implémenter reverse() pour les rails qui le supportent ; procédure manuelle documentée sinon.
  3. 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 commissionRate contractuel signé, son settlement échoue avec POLICY_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 leur commissionRate saisi 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