documentation
Kohinos v3 Doc corrigée le (vérification schéma Doctrine) commit 8d60b1904

Schéma de base de données — vue d'ensemble, tables critiques, FKs, index

La base Kohinos contient environ ~70 tables (100 classes d'entité dans src/Entity/, certaines étant des single table inheritance partageant une même table physique flux, operation_*, account_*). Le moteur cible est MariaDB en prod et SQLite in-memory en test (cf. migrations Doctrine). Cette page n'est pas auto-générée à partir du schéma : elle est tenue à la main pour rester lisible pour un DBA ou un nouveau contributeur, et doit être mise à jour à chaque migration qui touche structurellement un agrégat (table ajoutée, FK ajoutée, index critique modifié). Les identifiants de tables et colonnes sont en anglais ; la documentation, en français.

Vue d'ensemble — agrégats principaux

classDiagram
  class User { +int id +string username +string email +string password +string roles }
  class Adherent { +int id +User user +string firstname +string lastname }
  class Prestataire { +int id +User user +string name +string siret }
  class Comptoir { +int id +string name +Groupe groupe }
  class Groupe { +int id +string name }
  class AccountAdherent { +int id +Adherent owner +decimal balance }
  class AccountPrestataire { +int id +Prestataire owner +decimal balance }
  class AccountComptoir { +int id +Comptoir owner +decimal balance }
  class AccountGroupe { +int id +Groupe owner +decimal balance }
  class AccountSiege { +int id +decimal balance }
  class Flux { +int id +decimal montant +string type +string moyen +datetime createdAt }
  class Operation { +int id +decimal montant +string hash +string currency +bool historical }
  class SepaMandate { +int id +Adherent adherent +string mandateReference +string iban +string status }
  class SolidoumeParameter { +int id +string name +string distributionMode +bool enabled }
  class SolidoumeItem { +int id +Adherent adherent +SolidoumeParameter param +int lastMonthPayed }
  class HelloAsso { +int id +int helloassoId +decimal amount +string state }

  User "1" --> "0..1" Adherent
  User "1" --> "0..1" Prestataire
  Adherent "1" --> "1" AccountAdherent
  Prestataire "1" --> "1" AccountPrestataire
  Comptoir "1" --> "1" AccountComptoir
  Groupe "1" --> "1" AccountGroupe
  Comptoir "0..*" --> "1" Groupe
  Flux "1" --> "1..*" Operation
  Flux "0..*" --> "0..1" SolidoumeParameter
  SepaMandate "0..*" --> "1" Adherent
  SolidoumeItem "0..*" --> "1" Adherent
  SolidoumeItem "0..*" --> "1" SolidoumeParameter
  HelloAsso "0..*" --> "0..1" Adherent
    
Agrégats principaux. Volontairement limité à ~15 entités : les autres (Cotisation*, Don*, Transfert*, Vente*, Change*, Reconversion, Ticket*, CMS) héritent de Flux en STI et ne sont pas redessinées ici.

Domaines

1. Identité & adhésion

TableEntitéColonnes clésFKs
user User id, username (UNIQUE), email (UNIQUE), password (Argon2id), roles (json), enabled, last_login, confirmation_token —
adherent Adherent id, idmlc, payment_code, household_size NUMERIC(5,2), enabled, ecompte, created_at, updated_at
Note : firstname/lastname sont sur la table user, pas sur adherent. L'adresse est dans la table geoloc liée via geoloc_id.
geoloc_id → geoloc.id (1-1, nullable), groupe_id → groupe.id (nullable). Relation inverse : user.adherent_id → adherent.id
prestataire Prestataire id, raison (nom commercial), siret, slug (Gedmo), description, metier, responsable, iban, mlc, acceptemlc, enabled, created_at, updated_at
Adresse dans geoloc_prestataire via FK distincte.
typeprestataire_id → typeprestataire.id, groupe_id → groupe.id. Relation inverse : user.adherent_id peut pointer vers Prestataire via entité User liée.
comptoir Comptoir id, name, slug, idmlc, horaires, content, enabled, created_at, updated_at
Adresse dans geoloc liée via geoloc_id.
groupe_id → groupe.id, geoloc_id → geoloc.id (1-1, nullable)
groupe Groupe id, name, slug, description —
groupeprestataire Groupeprestataire id, name, slug groupe_id → groupe.id
typeprestataire TypePrestataire id, name, slug —
usergroup Usergroup id, name, roles (json) —
siege Siege id, name —

2. Comptes & monnaie eMLc

Chaque entité accountable (Adhérent, Prestataire, Comptoir, Groupe, Siège) possède exactement un compte, créé automatiquement via AccountableObject::postPersist() (cf. invariants financiers). Le solde (balance) est de type NUMERIC(10, 2) et reflète la somme algébrique des opérations associées.

TableOwnerColonnes clés
account_adherent adherent_id → adherent.id (1-1) id, balance NUMERIC(10,2), currency, created_at, updated_at
account_prestataire prestataire_id → prestataire.id (1-1) id, balance NUMERIC(10,2), currency, lifecycle timestamps
account_comptoir comptoir_id → comptoir.id (1-1) id, balance, currency
account_groupe groupe_id → groupe.id (1-1) id, balance, currency
account_siege siege_id → siege.id (1-1) id, balance, currency

3. Opérations — intégrité par hash

Une opération est l'écriture atomique unitaire au crédit ou au débit d'un compte. Chaque opération est hashée (BLAKE2b via hash), calculé à partir de son contenu. L'invariant somme des opérations = balance du compte est vérifié par bin/ai-workflows/financial/check-invariants.php.

TableCompte impactéColonnes clés
operation_adherent account_id → account_adherent.id id, flux_id, account_id, montant NUMERIC(10,2), currency, flux_data (json), extra_data (json), hash, historical, created_at, updated_at
operation_prestataire account_id → account_prestataire.id idem ci-dessus
operation_comptoir account_id → account_comptoir.id idem
operation_groupe account_id → account_groupe.id idem
operation_siege account_id → account_siege.id idem

4. Flux — STI (Single Table Inheritance)

Toutes les opérations métier (achat, vente, don, cotisation, transfert, reconversion, change…) sont matérialisées par une ligne dans la table flux avec un discriminator flux_type. Un même flux porte plusieurs opérations (au moins 2 : débiteur et créditeur) pour garantir l'équilibre comptable. Voir bounded contexts pour la cartographie des sous-classes.

Table physiqueSous-classes principalesColonnes clés
flux Flux (parent), Don, Transfert, Reconversion, AchatMonnaie, Cotisation, Change, Vente, Retrait, Transaction* id, discr (discriminateur STI), type, parenttype, montant NUMERIC(10,2), reference (clé métier SSA/SEPA), moyen (HelloAsso, PayZen, virement, chèque…), hash, historical, tauxreconversion, data (array), created_at, updated_at, solidoume_parameter_id (NULLABLE)
Note : il n'existe pas de table flux_euros ni flux_ssa séparée — le moyen de paiement est dans la colonne moyen directement sur flux.

Convention de référence (voir CLAUDE.md) : "Solidoume prélèvement {YYYYMM}", "Solidoume {YYYYMM}", "Solidoume cotisation {YYYYMM}", "Achat récurrent {YYYYMM}", "Cotisation MLC {YYYY}". Ces préfixes sont la clé d'identification pour les repos : ne JAMAIS renommer.

5. SEPA — mandats & requêtes récurrentes

TableColonnes clésFKs
sepa_mandate id, mandate_reference (UMR — UNIQUE, format KOHINOS-YYYYMMDD-XXXXXXXX), iban (chiffré), bic, account_holder_name, status (pending/active/cancelled/revoked), signed_at, validated_at, cancelled_at, created_at, updated_at
Note : il n'existe pas de statut validated — validated_at est un timestamp nullable indiquant la date de validation, mais le statut passe directement de pending à active.
adherent_id → adherent.id (nullable), prestataire_id → prestataire.id (nullable)
recurring_subscription_request id, request_type (discriminateur STI), amount, payment_method, status, validated_at, cancelled_at, auto_debit_date, created_at, updated_at adherent_id → adherent.id, sepa_mandate_id → sepa_mandate.id, solidoume_parameter_id → solidoume_parameter.id
recurring_purchase_request id, monthly_amount, debit_day (1-28), status, last_processed_month (int YYYYMM), validated_at, cancelled_at, created_at, updated_at sepa_mandate_id → sepa_mandate.id, adherent_id → adherent.id
iban_change_request id, new_iban (chiffré), new_bic, status, requested_at, validated_at adherent_id → adherent.id, old_mandate_id → sepa_mandate.id

6. SSA — Sécurité Sociale de l'Alimentation

TableColonnes clésFKs
solidoume_parameter id, name, slug, distribution_mode (calculated/proportional/tiers — défaut calculated), minimum, maximum, commission, execution_date, cotisation_type, enabled, created_at, updated_at
Note : il n'existe pas de colonne is_main ni rate — le caractère « principal » est déterminé par isMainSsa() (méthode PHP) et le taux n'est pas un champ simple. Pas de start_date.
—
solidoume_item id, last_month_payed (YYYYMM — idempotence mensuelle), amount, enabled adherent_id → adherent.id, solidoume_parameter_id → solidoume_parameter.id
prestataire_solidoume_parameter Table de liaison N-N : quels prestataires acceptent quels fonds SSA prestataire_id → prestataire.id, solidoume_parameter_id → solidoume_parameter.id

Voir documentation SSA dédiée pour les règles métier (taux, redistribution, prestataires éligibles, alertes « ancien participant »).

7. Paiements externes

TableColonnes clésFKs
helloasso id, helloassoid (int — identifiant HelloAsso, non UNIQUE en DB), amount, type, state, statepayment, payeremail, payerfirstname, payerlastname, data (json), enabled, created_at, updated_at
Note : l'idempotence webhook repose sur la logique applicative, pas sur un index UNIQUE DB. Le flux rattaché est unique via UNIQ(flux_id).
adherent_id → adherent.id (nullable — webhook peut arriver avant rattachement), flux_id → flux.id (nullable), user_id → user.id (nullable)
payum_payment (géré par bundle Payum) id, number, currency_code, total_amount, details (json), state —
payum_security_token hash, details (json), after_url, target_url, gateway_name —
pending_payment_data id, payload (json), state, created_at — buffer pour les paiements en cours de validation —

8. CMS

TableRôleColonnes clés
pagePages CMS éditables Sonataid, title, slug, content (HTML/Twig), enabled
newsActualitésid, title, slug, content, published_at
faqQuestions / réponsesid, question, answer, position, rubrique_id
rubriqueCatégorisation FAQ / docsid, name, slug, position
documentPièces jointes / PV / statutsid, filename, mime_type, size, uploaded_at
map · geoloc · geoloc_prestataireGéolocalisation des prestataireslat, lng, address · FK prestataire_id
contact_comptoir · contact_prestataireCoordonnées de contact secondairesid, name, email, phone

9. Système, audit & RGPD

TableRôleColonnes clés
audit_logTrace immuable des actions sensibles (cf. invariants)id, action, entity_class, entity_id, user_id, payload (json), created_at, hash
email_logTrace des emails sortants (Symfony Mailer)id, to_address, subject, template, sent_at, status, error
email_tokenTokens de confirmation (lien email)id, token (UNIQUE), purpose, expires_at
user_consentConsentements RGPD versionnésid, user_id, type, given_at, revoked_at, version
gdpr_requestDemandes d'accès / suppression RGPDid, user_id, type, status, description, created_at (date de demande), processed_at, processed_by_id, due_date
global_parameterParamètres de configuration de l'application (nom MLC, fonctionnalités activées, SEO, etc.)id, name (UNIQUE), description, value, mandatory
Note : il n'y a pas de colonne type et aucun paramètre de découvert — les soldes négatifs sont impossibles par construction.
cotisation_infosConfiguration cotisationsid, amount, year, type
cron_reportRapports de runs de commandes planifiées (bundle cron)id, job_id → cron_job.id, run_at, run_time, exit_code, output, error
messenger_messagesQueue Symfony Messenger (transport doctrine)id, body, headers, queue_name, created_at, available_at, delivered_at
ticket_fix · ticket_fix_destroy · ticket_fix_printModule B-Office / SAVcf. doc/BOFFICE.md
etat_prestataireHistorique d'état des prestatairesid, prestataire_id, state, changed_at
importHistorique des imports CSV (adhérents, prestataires)id, type, filename, row_count, status, imported_at

Index critiques

Ces index ne sont pas optionnels : ils garantissent l'idempotence ou la performance de processus financiers. Une migration qui les supprime doit être refusée en review.

IndexTableTypePourquoi
idx_helloasso_helloassoid helloasso UNIQUE Idempotence webhook HelloAsso : deux notifications du même paiement ne créent qu'une seule ligne. Cf. webhooks.
idx_sepa_mandate_reference sepa_mandate UNIQUE Unicité du UMR (Unique Mandate Reference) — exigence SEPA SDD Core, ne JAMAIS recycler.
idx_solidoume_item_idempotency solidoume_item(adherent_id, solidoume_parameter_id, last_month_payed) UNIQUE composite Empêche le double prélèvement SSA sur le même adhérent/fonds/mois — pierre angulaire du replay safety.
idx_flux_solidoume_parameter flux(solidoume_parameter_id) INDEX Permet la détection rapide des flux SSA (jointure répétée dans la commande mensuelle kohinos:ssa:execute).
idx_flux_reference flux(reference) INDEX Recherche par convention de référence ("Solidoume prélèvement YYYYMM", "Achat récurrent YYYYMM"…).
idx_account_adherent_balance account_adherent(balance) INDEX Vérification mensuelle que tous les soldes sont ≥ 0 (invariant absolu — aucun découvert possible par construction).
idx_sepa_mandate_last_processed_month sepa_mandate(last_processed_month) INDEX Filtre mensuel « mandats restant à traiter pour le mois courant ».
idx_messenger_queue_available messenger_messages(queue_name, available_at) INDEX composite Polling efficace du worker Symfony Messenger.

Champs sensibles chiffrés au repos

Sécurité — données à protéger
  • sepa_mandate.iban, sepa_mandate.bic, iban_change_request.new_iban, iban_change_request.new_bic : chiffrement actuel à base de openssl_* (legacy) — migration en cours vers libsodium (cf. app:migrate-iban-encryption --dry-run). Cf. cryptographie pour le détail des versions de chiffrement.
  • user.password : hashé Argon2id (paramétrage Symfony par défaut). Ne JAMAIS stocker en clair, ne JAMAIS migrer in-place un hash (versionner via password_version).
  • user.confirmation_token, email_token.token : tokens à usage unique, courte durée de vie — pas chiffrés mais aléatoires cryptographiquement forts.
  • operation_*.hash : hash d'intégrité calculé sur le contenu de l'opération — voir section suivante.
  • audit_log.hash : chaîne d'intégrité de la piste d'audit.

Lifecycle & callbacks Doctrine

Tables ne devant JAMAIS être éditées en direct

⚠ Édition manuelle = perte ou création silencieuse de monnaie
  • Toutes les colonnes *.hash (operation_*, audit_log) — modifier brise l'intégrité hashée, la prochaine vérification via check-invariants.php échouera.
  • La table flux et ses sous-classes STI — ne jamais insérer ou modifier directement. Toujours passer par les services dédiés (FluxService, SepaService, SsaService) qui appliquent les invariants : création des opérations équilibrées, chaînage, hash, signature, notifications.
  • Les tables operation_* — atomiques par construction : insérer ou modifier à la main casse aussitôt l'invariant somme des opérations = balance.
  • La colonne solidoume_item.last_month_payed — reculer la valeur déclenche un double prélèvement SSA au prochain run.
  • sepa_mandate.mandate_reference (UMR) — référence contractuelle pour la banque, JAMAIS modifier après signed_at.
  • helloasso.helloassoid — clé d'idempotence externe : la doublonner casse la déduplication des webhooks.

Pour toute correction : créer une compensating transaction via service, qui crée une opération inverse documentée et auditable. Voir invariants financiers.

Vues / requêtes utilitaires d'audit

Quelques SELECT à garder sous la main pour vérifier la santé de la base — exécuter en lecture seule, jamais via Sonata. Ces requêtes sont à brancher dans un dashboard self-host (Metabase, Grafana self-host) ou exposées par une commande kohinos:audit:*.

-- Somme algébrique des opérations vs balance affichée (doit être 0)
SELECT a.id, a.balance,
       (SELECT COALESCE(SUM(o.montant), 0) FROM operation_adherent o WHERE o.account_id = a.id) AS sum_ops,
       a.balance - (SELECT COALESCE(SUM(o.montant), 0) FROM operation_adherent o WHERE o.account_id = a.id) AS diff
FROM account_adherent a
HAVING diff != 0;

-- Masse monétaire eMLc totale (tous comptes confondus)
SELECT
  (SELECT COALESCE(SUM(balance), 0) FROM account_adherent)    AS adherents,
  (SELECT COALESCE(SUM(balance), 0) FROM account_prestataire) AS prestataires,
  (SELECT COALESCE(SUM(balance), 0) FROM account_comptoir)    AS comptoirs,
  (SELECT COALESCE(SUM(balance), 0) FROM account_groupe)      AS groupes,
  (SELECT COALESCE(SUM(balance), 0) FROM account_siege)       AS siege;

-- Soldes < 0 (ne devrait jamais arriver — aucun découvert autorisé par construction)
SELECT a.id, u.firstname, u.lastname, a.balance
FROM account_adherent a
JOIN adherent ad ON ad.id = a.adherent_id
JOIN user u ON u.adherent_id = ad.id
WHERE a.balance < 0
ORDER BY a.balance ASC;

-- Mandats SEPA actifs sans aucune requête récurrente associée (mandat orphelin)
SELECT sm.id, sm.mandate_reference, sm.signed_at
FROM sepa_mandate sm
WHERE sm.status = 'active'
  AND NOT EXISTS (SELECT 1 FROM recurring_subscription_request r WHERE r.sepa_mandate_id = sm.id)
  AND NOT EXISTS (SELECT 1 FROM recurring_purchase_request     r WHERE r.sepa_mandate_id = sm.id);

-- Webhooks HelloAsso reçus mais non rattachés (helloasso.adherent_id IS NULL après X heures)
SELECT id, helloassoid, amount, created_at, state
FROM helloasso
WHERE adherent_id IS NULL
  AND created_at < NOW() - INTERVAL 24 HOUR;

-- Détection double prélèvement SSA (devrait toujours être vide grâce à l'index UNIQUE)
SELECT adherent_id, solidoume_parameter_id, last_month_payed, COUNT(*) AS n
FROM solidoume_item
GROUP BY adherent_id, solidoume_parameter_id, last_month_payed
HAVING n > 1;

Maintien de cette doc

Procédure à appliquer à chaque migration structurelle
  • ☐ La migration ajoute / supprime une table ? → mettre à jour la section « Domaines » correspondante.
  • ☐ La migration ajoute / modifie un index critique ? → mettre à jour la table « Index critiques ».
  • ☐ La migration ajoute un champ chiffré ? → mettre à jour « Champs sensibles chiffrés au repos » et cryptographie.
  • ☐ Un nouvel agrégat apparaît (au sens DDD, pas juste une table de liaison) ? → regénérer le diagramme Mermaid à la main, garder ≤ 20 entités.
  • ☐ Une table devient critique (intégrité financière, idempotence, audit) ? → l'ajouter au callout « Tables ne devant JAMAIS être éditées en direct ».
  • ☐ Vérifier que les noms de tables citées ici correspondent bien à #[ORM\Table(name: '...')] dans les entités — ne pas se fier au nom de classe.

Anti-objectif : générer cette page automatiquement à partir du schéma Doctrine. Ce serait illisible (~70 tables avec leurs ~600 colonnes). Cette page est une carte, pas un atlas.

Pour aller plus loin

Sources & liens
  • src/Entity/ — 100 classes d'entité (certaines en STI sur la même table).
  • src/Migrations/ — historique des changements de schéma.
  • config/packages/doctrine.yaml — mapping type: attribute.
  • config/packages/test/doctrine.yaml — bascule SQLite in-memory.
  • doc/DOMAIN.md — modèle de domaine côté métier.
  • Vue domaine — agrégats et règles métier.
  • Invariants financiers — règles d'intégrité non négociables.
  • Migrations Doctrine — règles d'écriture, SQLite ⇄ MariaDB.
  • Cryptographie — chiffrement IBAN, hash opérations, signatures.
  • SSA · Webhooks · Sonata Admin