documentation
Kohinos v3 Doc générée le commit 8d60b1904

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.

⚠ Règle absolue

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).

Carte interactive des 22 invariants — cliquez ou filtrez

Cliquez une cellule pour voir le détail de l'invariant.
Conservation Atomicité Chaînage SSA SEPA Idempotence
N° Invariant Catégorie Vérifié par
1Balance de account_adherent = Σ operation_adherent.montantconservationcheck-invariants.php · tests/Service/OperationService*
2Balance de account_prestataire = Σ operation_prestataire.montantconservationcheck-invariants.php
3Balance de account_comptoir = Σ operation_comptoir.montantconservationcheck-invariants.php
4Balance de account_groupe = Σ operation_groupe.montantconservationcheck-invariants.php
5Balance de account_siege = Σ operation_siege.montant (peut être négative)conservationcheck-invariants.php
6account_adherent.balance jamais < 0conservationcheck-invariants.php · OperationUtils
7account_prestataire.balance jamais < 0conservationcheck-invariants.php
8account_comptoir.balance jamais < 0conservationcheck-invariants.php
9account_groupe.balance jamais < 0conservationcheck-invariants.php
10Pas de doublon de webhook HelloAsso (un helloassoid = une ligne)idempotencecheck-invariants.php · HelloAssoWebhookController
11Toute ligne HelloAsso validée doit pointer sur un flux_idchaînagecheck-invariants.php
12Cohérence statut SEPA ↔ timestamps (signed_at, cancelled_at)temporelcheck-invariants.php
13Une partie (adhérent ou prestataire) n'a jamais 2 mandats SEPA activeidempotencecheck-invariants.php · SepaMandateRepository
14Toute demande de changement d'IBAN finalisée tracée (qui & quand)temporelcheck-invariants.php · IbanChangeRequestService
15Pas 2 flux SSA sur même adhérent + même caisse + même mois civilidempotencecheck-invariants.php · SsaTakingService
16recurring_purchase_request.debit_day ∈ [1..28] quand statut activetemporelcheck-invariants.php (à confirmer dans le code — la requête tolère 1..31)
17Souscription récurrente SEPA validated ⇒ mandat existant actifchaînagecheck-invariants.php
18Achat récurrent active ⇒ mandat SEPA pointé est activechaînagecheck-invariants.php
19Aucune operation_* orpheline (toute opération pointe sur un flux existant)chaînagecheck-invariants.php
20Tout flux non-historique avec montant > 0 a au moins 1 operation_*atomicitécheck-invariants.php · ImportController (fix 2026-04-21)
21Pas de signature d'import historique dupliquée (reference + montant + parties + minute)idempotencecheck-invariants.php
22flux.hash jamais à la sentinelle 'tmp' après lifecycle (libsodium calculé)chaînagecheck-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é.

Pourquoi cinq invariants distincts plutôt qu'un seul

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 :

// 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
});
⚠ Régression historique — corrigée 2026-04-21

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 :

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 :

// 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'
);
⚠ Rappel — ces invariants ne sont pas négociables

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

Sources
  • bin/ai-workflows/financial/check-invariants.php — script canonique des invariants SQL
  • src/Service/OperationUtils.php — barre d'écriture financière (locks, refus découvert)
  • src/EventListener/FluxListener.php — chaînage cryptographique hash
  • src/Service/Ssa/SsaTakingService.php · SsaRedistributionService.php
  • src/Service/Sepa/ — mandats, requêtes récurrentes, processus mensuel
  • doc/DOMAIN.md · doc/ARCHITECTURE.md — modèle entités & patterns
  • docs/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)