Les 22 invariants financiers
Kohinos est une infrastructure monétaire communautaire. La priorité n°1 du
projet est l'intégrité financière : jamais de perte, jamais de création
silencieuse de monnaie locale. Ces 22 invariants sont les assertions inviolables qui
matérialisent cette priorité. Ils sont audités par le script
bin/ai-workflows/financial/check-invariants.php, traduits en tests
PHPUnit ciblés, et défendus en revue de code. Un patch qui casse l'un d'entre eux
est rejeté — même s'il améliore les performances.
Ces invariants ne sont pas négociables. Un test ou un patch qui les viole est refusé.
Toute évolution qui remet en cause un invariant doit faire l'objet d'une décision documentée
dans docs/adr/ avant d'être mergée. Aucun raccourci : l'argent local
d'une coopérative citoyenne ne supporte pas l'à-peu-près.
Synthèse — table des 22 invariants
Catégories : conservation (la monnaie ne se crée ni ne se perd), atomicité (tout-ou-rien sur les écritures), chaînage (intégrité cryptographique des flux), temporel (cohérence des dates & statuts), idempotence (un événement = une écriture, jamais deux).
| N° | Invariant | Catégorie | Vérifié par |
|---|---|---|---|
| 1 | Balance de account_adherent = Σ operation_adherent.montant | conservation | check-invariants.php · tests/Service/OperationService* |
| 2 | Balance de account_prestataire = Σ operation_prestataire.montant | conservation | check-invariants.php |
| 3 | Balance de account_comptoir = Σ operation_comptoir.montant | conservation | check-invariants.php |
| 4 | Balance de account_groupe = Σ operation_groupe.montant | conservation | check-invariants.php |
| 5 | Balance de account_siege = Σ operation_siege.montant (peut être négative) | conservation | check-invariants.php |
| 6 | account_adherent.balance jamais < 0 | conservation | check-invariants.php · OperationUtils |
| 7 | account_prestataire.balance jamais < 0 | conservation | check-invariants.php |
| 8 | account_comptoir.balance jamais < 0 | conservation | check-invariants.php |
| 9 | account_groupe.balance jamais < 0 | conservation | check-invariants.php |
| 10 | Pas de doublon de webhook HelloAsso (un helloassoid = une ligne) | idempotence | check-invariants.php · HelloAssoWebhookController |
| 11 | Toute ligne HelloAsso validée doit pointer sur un flux_id | chaînage | check-invariants.php |
| 12 | Cohérence statut SEPA ↔ timestamps (signed_at, cancelled_at) | temporel | check-invariants.php |
| 13 | Une partie (adhérent ou prestataire) n'a jamais 2 mandats SEPA active | idempotence | check-invariants.php · SepaMandateRepository |
| 14 | Toute demande de changement d'IBAN finalisée tracée (qui & quand) | temporel | check-invariants.php · IbanChangeRequestService |
| 15 | Pas 2 flux SSA sur même adhérent + même caisse + même mois civil | idempotence | check-invariants.php · SsaTakingService |
| 16 | recurring_purchase_request.debit_day ∈ [1..28] quand statut active | temporel | check-invariants.php (à confirmer dans le code — la requête tolère 1..31) |
| 17 | Souscription récurrente SEPA validated ⇒ mandat existant actif | chaînage | check-invariants.php |
| 18 | Achat récurrent active ⇒ mandat SEPA pointé est active | chaînage | check-invariants.php |
| 19 | Aucune operation_* orpheline (toute opération pointe sur un flux existant) | chaînage | check-invariants.php |
| 20 | Tout flux non-historique avec montant > 0 a au moins 1 operation_* | atomicité | check-invariants.php · ImportController (fix 2026-04-21) |
| 21 | Pas de signature d'import historique dupliquée (reference + montant + parties + minute) | idempotence | check-invariants.php |
| 22 | flux.hash jamais à la sentinelle 'tmp' après lifecycle (libsodium calculé) | chaînage | check-invariants.php · FluxListener |
Préambule — l'arithmétique monétaire chez Kohinos
Avant tout invariant : la représentation de la monnaie. Kohinos n'utilise
jamais de float pour de l'argent. Les colonnes sont en
DECIMAL(10,2) (legacy DOUBLE en cours de migration), les calculs
en mémoire passent par bcmath ou brick/math via des chaînes de
caractères. Toute introduction d'un cast (float) sur un champ monétaire est
bloquée en revue.
// ❌ INTERDIT — perte de précision
$total = (float) $amount + (float) $commission;
// ✅ OBLIGATOIRE — précision arbitraire
$total = bcadd((string) $amount, (string) $commission, 2);
// ou via brick/math
$total = BigDecimal::of((string) $amount)->plus((string) $commission);
Et un principe de réserve : 1 eMLc émis ⇔ 1 € en réserve bancaire. L'émission de monnaie locale n'est pas un acte purement logiciel, c'est la contrepartie d'un virement HelloAsso, SEPA ou comptoir physique. Tous les invariants ci-dessous protègent ce pacte : aucun eMLc ne doit exister sans contrepartie, ni inversement.
1. Conservation de la monnaie (invariants 1 → 9)
Le signe de operation_*.montant est signé : positif pour un crédit,
négatif pour un débit. La balance d'un compte est donc structurellement la
somme algébrique de ses opérations. Si balance ≠ SUM(operations), soit une
opération est manquante, soit la balance a été modifiée hors lifecycle : les deux cas
sont des bugs critiques.
Invariants 1–5 — balance = somme des opérations (5 types d'accounts)
SELECT a.id, a.balance AS stored_balance,
COALESCE(SUM(o.montant), 0) AS computed_from_operations,
(a.balance - COALESCE(SUM(o.montant), 0)) AS drift
FROM account_adherent a
LEFT JOIN operation_adherent o ON o.account_id = a.id
WHERE a.enabled = 1
GROUP BY a.id, a.balance
HAVING ABS(a.balance - COALESCE(SUM(o.montant), 0)) > 0.001;
-- doit toujours retourner 0 lignes
L'invariant existe en cinq déclinaisons : adherent, prestataire,
comptoir, groupe, siege. Le compte siege est
le seul à pouvoir être négatif : c'est le compte émetteur nanti — son négatif représente
précisément la quantité d'eMLc en circulation.
Vérifié par : bin/ai-workflows/financial/check-invariants.php
(familles 1 et 2) · tests d'opérations dans tests/Service/Operation*.
Invariants 6–9 — pas de solde négatif sur les comptes « classiques »
SELECT id, currency, balance, created_at
FROM account_adherent
WHERE balance < 0 AND enabled = 1;
-- doit toujours retourner 0 lignes
-- (idem pour account_prestataire, account_comptoir, account_groupe)
Le découvert autorisé est piloté par GlobalParameter (valeurs configurables par
rôle), mais à l'instant t de la requête SQL, aucun compte non-siège ne doit être
en négatif. L'écriture est barrée en amont par OperationUtils::executeOperations()
qui refuse une opération si elle conduirait à un solde négatif non autorisé.
Le schéma de Kohinos hérite d'une période avant la généralisation des FK ON DELETE.
Les cinq tables operation_* ne partagent ni table commune ni discriminateur STI.
Vérifier la conservation table par table est plus lent mais bien plus diagnostique : la
violation pointe directement le type de compte fautif.
2. Atomicité des écritures (invariant 20)
Un Flux est l'événement métier (achat, virement, cotisation). Les
Operation_* sont ses effets comptables sur les comptes. Aucun flux ne doit
exister sans son effet : sinon, le client a payé mais aucun compte n'a bougé
(perte sèche), ou un compte a bougé sans trace (création silencieuse).
Invariant 20 — tout flux non-historique a au moins une opération
SELECT f.id, f.type, f.parenttype, f.montant, f.created_at
FROM flux f
WHERE f.historical = 0 AND f.montant > 0
AND NOT EXISTS (SELECT 1 FROM operation_adherent WHERE flux_id = f.id)
AND NOT EXISTS (SELECT 1 FROM operation_prestataire WHERE flux_id = f.id)
AND NOT EXISTS (SELECT 1 FROM operation_comptoir WHERE flux_id = f.id)
AND NOT EXISTS (SELECT 1 FROM operation_groupe WHERE flux_id = f.id)
AND NOT EXISTS (SELECT 1 FROM operation_siege WHERE flux_id = f.id);
-- doit toujours retourner 0 lignes
L'atomicité est garantie par :
- Verrouillage pessimiste :
EntityManager::lock($account, LockMode::PESSIMISTIC_WRITE)sur tous les comptes impliqués avant lecture de balance et écriture (cf.SsaRedistributionService,OperationUtils). - Transaction unique :
EntityManager::wrapInTransaction()(Doctrine 2.14+) enrobe la création du flux + ses operations. Tout throw remonte le rollback complet — pas de demi-état persisté. - Pas de flush dans les EventSubscriber lifecycle : les
postPersist/postUpdaten'appellent jamaisflush(), pour ne pas casser le tour de transaction de Doctrine (cf.FluxListener).
// Pattern canonique pour une écriture financière atomique
$this->em->wrapInTransaction(function () use ($flux, $operations) {
$this->em->lock($flux->getAccountFrom(), LockMode::PESSIMISTIC_WRITE);
$this->em->lock($flux->getAccountTo(), LockMode::PESSIMISTIC_WRITE);
$this->em->persist($flux);
foreach ($operations as $op) {
$this->em->persist($op);
}
// pas besoin de flush() explicite : wrapInTransaction l'appelle
});
ImportController::importOperations commitait n lignes en bulk avec un
seul flush() en fin de boucle. Un crash en milieu d'import laissait des flux
sans operations. Depuis 2026-04-21, chaque ligne est wrapInTransaction isolée. La
détection des survivants est l'invariant 20.
Vérifié par : check-invariants.php · audit
docs/audits/race_conditions_financial.md · tests d'import dans
tests/Controller/Admin/Import/.
3. Chaînage cryptographique (invariants 11, 17, 18, 19, 22)
Chaque flux et chaque operation_* embarque un champ
hash. La fonction de hachage est libsodium
(sodium_crypto_generichash, BLAKE2b), avec à terme une signature
Ed25519 détachée pour les flux d'émission/destruction monétaire. Pas
d'openssl_* : refus automatique en revue (cohérent avec la roadmap
Reticulum / LoRa offline). Voir .claude/rules/global.md.
Invariant 22 — chaînage flux.hash calculé
SELECT id, type, parenttype, created_at
FROM flux
WHERE hash = 'tmp' OR hash IS NULL OR hash = '';
-- doit toujours retourner 0 lignes
À la création, Flux::$hash est initialisé à la sentinelle 'tmp' pour
satisfaire la contrainte NOT NULL. Le FluxListener
(postPersist) calcule alors le hash chaîné :
$payload = sprintf(
'%s|%s|%s|%s|%s',
$flux->getId(),
$flux->getMontant(),
$flux->getCreatedAt()->format(DATE_ATOM),
$previousFlux?->getHash() ?? '', // chaînage avec le précédent
$flux->getReference() ?? '',
);
$hash = sodium_bin2hex(sodium_crypto_generichash($payload));
$flux->setHash($hash);
Le chaînage (inclusion du hash du flux précédent) rend toute altération rétroactive
détectable : modifier un flux ancien casse tous les hashs suivants. Les hash
sont immuables après création : aucune setter publique, aucune migration
ne doit les réécrire. (à confirmer dans le code pour la setter publique selon l'état de la
branche en cours.)
Invariant 19 — pas d'opération orpheline
SELECT o.id, o.flux_id, o.account_id, o.montant
FROM operation_adherent o
LEFT JOIN flux f ON f.id = o.flux_id
WHERE f.id IS NULL;
-- doit toujours retourner 0 lignes (idem pour les 5 tables operation_*)
Une opération sans flux parent est une écriture orpheline. Symétrique de l'invariant 20.
Invariants 11, 17, 18 — toute écriture validée pointe sur sa source active
Trois cas de figure du même principe : si un événement métier est marqué « validé », il DOIT pointer sur sa cause encore active.
-- 11. HelloAsso validé sans flux comptable
SELECT id, helloassoid, operation_state, amount
FROM helloasso
WHERE operation_state IN ('validated', 'completed', 'ok')
AND flux_id IS NULL AND enabled = 1;
-- 17. Souscription récurrente SEPA validated sans mandat actif
SELECT r.id, r.payment_method, r.status, m.status AS mandate_status
FROM recurring_subscription_request r
LEFT JOIN sepa_mandate m ON m.id = r.sepa_mandate_id
WHERE r.payment_method = 'sepa' AND r.status = 'validated'
AND (r.sepa_mandate_id IS NULL OR m.status != 'active');
-- 18. Achat récurrent actif avec mandat non-actif
SELECT rpr.id, rpr.status, sm.status AS mandate_status
FROM recurring_purchase_request rpr
LEFT JOIN sepa_mandate sm ON sm.id = rpr.sepa_mandate_id
WHERE rpr.status = 'active'
AND (sm.id IS NULL OR sm.status != 'active');
4. SSA — caisse, idempotence, redistribution (invariant 15)
La SSA (Sécurité Sociale de l'Alimentation dite SSA) est multi-caisses
(SolidoumeParameter), multi-stratégies (Calculated,
Fixed, Tiers), et opère en boucle mensuelle. L'idempotence et la
conservation sont protégées par plusieurs garde-fous.
Invariant 15 — un seul prélèvement SSA par adhérent & par caisse & par mois
SELECT adherent_id, solidoume_parameter_id,
YEAR(created_at) AS y, MONTH(created_at) AS m,
COUNT(*) AS collections, GROUP_CONCAT(id) AS flux_ids
FROM flux
WHERE solidoume_parameter_id IS NOT NULL
AND adherent_id IS NOT NULL
GROUP BY adherent_id, solidoume_parameter_id, y, m
HAVING COUNT(*) > 1;
-- doit toujours retourner 0 lignes
Trois garde-fous le matérialisent dans le code :
- Détection canonique d'un flux SSA :
flux.solidoume_parameter_id IS NOT NULL. Pas de filtre parparenttypenidiscr: l'absence de FK suffit à exclure les flux non-SSA. - Anti-rejeu mensuel :
SolidoumeItem::lastMonthPayed(formatYYYYMM) est comparé au mois en cours avant tout prélèvement ;SsaTakingServicesaute l'item si égal. - Convention de référence : la
referencedu flux suit les patterns duCLAUDE.md(Solidoume prélèvement {month},Solidoume {month},Solidoume cotisation {month}) — recherche indexée fiable.
La conservation sur redistribution est un sous-invariant : la somme
cotisée + commission entrante doit égaler la somme redistribuée + commission sortante. Le
RedistributionStrategyInterface::computeBasePerParticipant() renvoie un
BigDecimal-string ; SsaRedistributionService lock-write les comptes
impliqués, lit les soldes après lock, puis émet les opérations dans une transaction unique.
(à confirmer dans le code pour l'assertion explicite de conservation sur la totalité de
la caisse — la stratégie tolère un reliquat <0,01 eMLc absorbé par la commission.)
5. SEPA — mandats, prélèvements récurrents (invariants 12, 13, 14, 16)
Les statuts réels de sepa_mandate sont : pending,
active, cancelled, revoked. Il n'existe pas
de statut validated sur un mandat SEPA — c'est une confusion fréquente
avec recurring_subscription_request.status.
Invariant 12 — cohérence statut / timestamps
SELECT id, mandate_reference, status, signed_at, validated_at, cancelled_at
FROM sepa_mandate
WHERE
(status IN ('cancelled', 'revoked') AND cancelled_at IS NULL)
OR (status = 'active' AND signed_at IS NULL)
OR (cancelled_at IS NOT NULL AND status NOT IN ('cancelled', 'revoked'));
-- doit toujours retourner 0 lignes
Un mandat actif sans signed_at est un mandat fantôme : il
prélèverait sans base juridique. Un mandat avec cancelled_at mais statut
non-terminal est récupérable à tort.
Invariant 13 — pas de doublon de mandat actif par partie
SELECT COALESCE(adherent_id, prestataire_id) AS party_id,
COUNT(*) AS active_mandates,
GROUP_CONCAT(id) AS mandate_ids
FROM sepa_mandate
WHERE status = 'active'
AND (adherent_id IS NOT NULL OR prestataire_id IS NOT NULL)
GROUP BY party_id
HAVING COUNT(*) > 1;
-- doit toujours retourner 0 lignes
Deux mandats actifs simultanés pour la même partie ouvrent la voie au double
prélèvement. La transition d'un mandat à un autre (changement d'IBAN) doit passer par
cancelled du précédent avant active du nouveau, dans la même
transaction.
Invariant 14 — traçabilité des changements d'IBAN
SELECT id, prestataire_id, current_iban, new_iban, status, processed_at, processed_by_id
FROM iban_change_request
WHERE status IN ('validated', 'rejected')
AND (processed_at IS NULL OR processed_by_id IS NULL);
-- doit toujours retourner 0 lignes
Tout changement d'IBAN finalisé porte la trace de qui a décidé et quand. Les IBAN eux-mêmes
sont chiffrés en base (cf. app:migrate-iban-encryption) — la traçabilité s'ajoute
au chiffrement, elle ne le remplace pas.
Invariant 16 — jour de prélèvement valide [1..28]
SELECT id, sepa_mandate_id, monthly_amount, debit_day, status
FROM recurring_purchase_request
WHERE (debit_day < 1 OR debit_day > 31)
AND status = 'active';
-- doit toujours retourner 0 lignes (en pratique 1..28, voir ci-dessous)
La règle 1..28 évite les mois courts (février) qui décaleraient un prélèvement
prévu le 30. La requête SQL du script utilise 1..31 (filet large) ; la
validation Symfony des formulaires (Constraint\Range(min: 1, max: 28)) est
plus stricte. (à confirmer dans le code : aligner check-invariants.php sur 1..28
pour rester cohérent avec la validation applicative.)
Idempotence SEPA mensuelle : SepaMandate::$lastProcessedMonth (format
YYYYMM) empêche le double traitement par kohinos:sepa:process-monthly.
Référence UMR : KOHINOS-YYYYMMDD-XXXXXXXX (8 premiers chars de l'UUID),
gravée à la création via le pattern double-flush du CLAUDE.md.
6. Comptes — solde temps réel, jamais de cache (rappel transversal)
Le solde d'un compte est toujours recalculé depuis les opérations à la
lecture. Aucune valeur balance dénormalisée n'est servie à un endroit du code
sans avoir été préalablement réconciliée avec la table operation_* correspondante.
Les invariants 1–5 sont l'expression SQL de cette règle.
Le plafond de découvert autorisé est lu dans GlobalParameter
(maxDecouvertAdherent, maxDecouvertPrestataire, etc.). Les
invariants 6–9 sont le filet de sécurité a posteriori — la barre principale est
OperationUtils::executeOperations() qui refuse l'écriture si elle
franchirait la limite. Le compte account_siege est exclu : son négatif est
la masse monétaire émise en circulation.
Un compte est auto-créé via AccountableObject::postPersist()
(lifecycle Doctrine, attributs PHP 8 — pas de docblock). Une rupture du mapping
type: attribute casserait cette création silencieuse, c'est l'une des règles
absolues du CLAUDE.md.
7. Idempotence des imports historiques (invariants 10, 21)
Invariant 10 — pas de double webhook HelloAsso
SELECT helloassoid, COUNT(*) AS occurrences, GROUP_CONCAT(id) AS row_ids
FROM helloasso
WHERE enabled = 1
GROUP BY helloassoid
HAVING COUNT(*) > 1;
-- doit toujours retourner 0 lignes
Un même paiement HelloAsso (identifiant helloassoid) ne doit générer qu'une
seule ligne. HelloAsso peut rejouer un webhook si notre endpoint ne répond pas
200 dans les temps : l'idempotence se fait côté Kohinos, par une
contrainte unique sur helloassoid + un check applicatif en début de
HelloAssoWebhookController.
Invariant 21 — pas de signature d'import historique dupliquée
SELECT reference, montant, adherent_id, prestataire_id,
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i') AS minute,
COUNT(*) AS dup
FROM flux
WHERE historical = 1 AND reference IS NOT NULL AND reference != ''
GROUP BY reference, montant, adherent_id, prestataire_id, minute
HAVING COUNT(*) > 1;
-- doit toujours retourner 0 lignes (sinon import rejoué)
Signature « molle » à la minute près : si deux flux historiques partagent
(reference, montant, expéditeur, destinataire, minute), c'est très probablement
un double-clic sur le bouton « importer » ou un rejeu manuel post-panne. La restriction à
historical = 1 évite de flagger deux achats légitimes du même jour avec même
montant en exploitation normale.
Comment auditer l'état de la base
Audit ad-hoc :
php bin/ai-workflows/financial/check-invariants.php --env=prod
# JSON sur stdout, exit 0 si clean, exit 1 si au moins une violation
Audit sélectif :
php bin/ai-workflows/financial/check-invariants.php --env=prod \
--skip=helloasso_no_duplicate_webhook,pending_payment_not_expired
Audit programmé : déclenché en CI sur chaque PR via la base de test, et sur la prod
via cron horaire avec alerte Monolog → GlitchTip self-host si verdict: fail
(cf. doc/DEVOPS).
Implications pour l'écriture de tests
Tout nouveau test métier qui manipule de la monnaie doit, dans son tearDown()
ou en assertion finale :
- vérifier la conservation :
$balanceBefore + $delta === $balanceAfterenbcmath, jamais===sur des floats ; - vérifier l'atomicité : aucun flux orphelin d'opérations, aucun compte de partie non impliquée modifié ;
- vérifier le chaînage :
$flux->getHash() !== 'tmp'aprèsflush()(le listener doit avoir tourné) ; - vérifier l'idempotence : rappeler le service deux fois ne doit écrire qu'une fois (cas SSA mensuel, webhook HelloAsso, SEPA mensuel).
// Squelette d'assertion de conservation
$balanceBefore = $this->accountRepository->find($id)->getBalance();
$service->executeFlux($flux);
$balanceAfter = $this->accountRepository->find($id)->getBalance();
$this->assertSame(
bcadd((string) $balanceBefore, (string) $flux->getMontant(), 2),
(string) $balanceAfter,
'Conservation violée — invariants 1..5'
);
Si un patch les casse, il est rejeté. Si une performance les fragilise, on choisit la performance plus modeste. Si un refactoring les rend plus difficiles à exprimer en SQL, on garde l'expression SQL — c'est la dernière ligne de défense contre la dérive silencieuse. L'intégrité financière l'emporte toujours.
Pour aller plus loin
bin/ai-workflows/financial/check-invariants.php— script canonique des invariants SQLsrc/Service/OperationUtils.php— barre d'écriture financière (locks, refus découvert)src/EventListener/FluxListener.php— chaînage cryptographiquehashsrc/Service/Ssa/SsaTakingService.php·SsaRedistributionService.phpsrc/Service/Sepa/— mandats, requêtes récurrentes, processus mensueldoc/DOMAIN.md·doc/ARCHITECTURE.md— modèle entités & patternsdocs/audits/race_conditions_financial.md— audit ayant donné les invariants 20/21/22.claude/rules/global.md— priorités projet (intégrité financière en n°1).claude/rules/money.md— règles d'atomicité & idempotence (si présent)