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
| Table | Entité | Colonnes clés | FKs |
|---|---|---|---|
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_atNote : 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_atAdresse 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_atAdresse 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.
| Table | Owner | Colonnes 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.
| Table | Compte 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 physique | Sous-classes principales | Colonnes 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
| Table | Colonnes clés | FKs |
|---|---|---|
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_atNote : 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
| Table | Colonnes clés | FKs |
|---|---|---|
solidoume_parameter |
id, name, slug, distribution_mode (calculated/proportional/tiers — défaut calculated), minimum, maximum, commission, execution_date, cotisation_type, enabled, created_at, updated_atNote : 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
| Table | Colonnes clés | FKs |
|---|---|---|
helloasso |
id, helloassoid (int — identifiant HelloAsso, non UNIQUE en DB), amount, type, state, statepayment, payeremail, payerfirstname, payerlastname, data (json), enabled, created_at, updated_atNote : 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
| Table | Rôle | Colonnes clés |
|---|---|---|
page | Pages CMS éditables Sonata | id, title, slug, content (HTML/Twig), enabled |
news | Actualités | id, title, slug, content, published_at |
faq | Questions / réponses | id, question, answer, position, rubrique_id |
rubrique | Catégorisation FAQ / docs | id, name, slug, position |
document | Pièces jointes / PV / statuts | id, filename, mime_type, size, uploaded_at |
map · geoloc · geoloc_prestataire | Géolocalisation des prestataires | lat, lng, address · FK prestataire_id |
contact_comptoir · contact_prestataire | Coordonnées de contact secondaires | id, name, email, phone |
9. Système, audit & RGPD
| Table | Rôle | Colonnes clés |
|---|---|---|
audit_log | Trace immuable des actions sensibles (cf. invariants) | id, action, entity_class, entity_id, user_id, payload (json), created_at, hash |
email_log | Trace des emails sortants (Symfony Mailer) | id, to_address, subject, template, sent_at, status, error |
email_token | Tokens de confirmation (lien email) | id, token (UNIQUE), purpose, expires_at |
user_consent | Consentements RGPD versionnés | id, user_id, type, given_at, revoked_at, version |
gdpr_request | Demandes d'accès / suppression RGPD | id, user_id, type, status, description, created_at (date de demande), processed_at, processed_by_id, due_date |
global_parameter | Paramètres de configuration de l'application (nom MLC, fonctionnalités activées, SEO, etc.) | id, name (UNIQUE), description, value, mandatoryNote : il n'y a pas de colonne type et aucun paramètre de découvert — les soldes négatifs sont impossibles par construction. |
cotisation_infos | Configuration cotisations | id, amount, year, type |
cron_report | Rapports de runs de commandes planifiées (bundle cron) | id, job_id → cron_job.id, run_at, run_time, exit_code, output, error |
messenger_messages | Queue Symfony Messenger (transport doctrine) | id, body, headers, queue_name, created_at, available_at, delivered_at |
ticket_fix · ticket_fix_destroy · ticket_fix_print | Module B-Office / SAV | cf. doc/BOFFICE.md |
etat_prestataire | Historique d'état des prestataires | id, prestataire_id, state, changed_at |
import | Historique 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.
| Index | Table | Type | Pourquoi |
|---|---|---|---|
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
sepa_mandate.iban,sepa_mandate.bic,iban_change_request.new_iban,iban_change_request.new_bic: chiffrement actuel à base deopenssl_*(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 viapassword_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
AccountableObject::postPersist()—#[ORM\HasLifecycleCallbacks]sur laMappedSuperclass. À la création d'un Adherent / Prestataire / Comptoir / Groupe / Siege, une ligne dans la tableaccount_*correspondante est créée automatiquement avec un solde 0. Casse silencieusement si l'attribut PHP 8#[ORM\MappedSuperclass]est omis (le callback n'est pas câblé).#[Gedmo\Slug(fields: [...])]sur les entités CMS (Page,News,Faq,Rubrique,Prestataire,Comptoir,Groupe…) pour générer un slug à partir du nom. Lifecycle : pré-persist & pré-update. Sans attribut PHP 8, le slug reste NULL.#[ORM\PrePersist]/#[ORM\PreUpdate]sur les entités qui dérivent l'updated_atmanuellement (SQLite ne supporte pasDEFAULT CURRENT_TIMESTAMP ON UPDATE).- Pattern double-flush UUID : pour les entités dont une
colonne dépend de l'UUID auto-généré (
SepaMandate.mandate_reference), on flushe une première fois pour obtenir l'UUID, on dérive la référence, on flushe à nouveau. Cf. CLAUDE.md.
Tables ne devant JAMAIS être éditées en direct
- Toutes les colonnes
*.hash(operation_*,audit_log) — modifier brise l'intégrité hashée, la prochaine vérification viacheck-invariants.phpéchouera. - La table
fluxet 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èssigned_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
- ☐ 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
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— mappingtype: 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