# Avancement — Lot 4 (Facturation & gestion financière, Module 4, §7 du cahier des charges v4.1)

*Même principe que `documentation/avancement-lot3.md`. Points hors
scope consolidés dans `documentation/backlog-v2.md` ; questions de
conception ouvertes dans `documentation/decisions-a-prendre.md`.*

## §7.2 Fonctionnalités

| § | Fonctionnalité | État | Détail |
| --- | --- | --- | --- |
| **a)** | **Grille tarifaire** | ✅ | Lignes libres (horaire/journalier/forfait mensuel/forfait unique), réduction fratrie (pourcentage) — 2026-08-04 |
| **b)** | **Génération des factures** | ✅ | Forfait fixe et planning contractuel, PDF (Dompdf), mise à disposition dans l'espace parent — 2026-08-04 |
| **c)** | **Retrait de l'enfant en cours de contrat** | ✅ | Proposition de solde calculée, frais de préavis, exécution manuelle — 2026-08-04 |
| **d)** | **Suivi des paiements** | ✅ | Paiements, statut dérivé, vue d'ensemble, relances — 2026-08-04 |
| e) | Acomptes & dépôt de garantie | ➖ | Sans objet, confirmé par le client — 2026-08-05 |
| **f)** | **Avances et apports du fondateur** | ✅ | Catégories neutres, pièce justificative, saisie Comptabilité/validation Direction — 2026-08-04 |
| **g)** | **Cantine** | ✅ | Facturation par repas consommé, gestion annulé/offert — 2026-08-05 |
| **h)** | **Suivi de trésorerie** | ✅ | Dépenses (double validation), entrants/sortants catégorisés, solde — 2026-08-04 |
| **i)** | **Comptabilité générale (export)** | ✅ | Export CSV factures/paiements, filtrable par date — 2026-08-04 |
| **j)** | **Séparation des rôles & validations financières** | ✅ | 5/5 actions couvertes : avance fondateur (f), dépense (h), remboursement, annuler une facture, modifier un paiement clôturé — 2026-08-04/05 |

## Détail de a) Grille tarifaire (livré le 2026-08-04)

Premier incrément du Module 4, pris en premier car b)/e)/g)/h) en
dépendent tous (même logique que d) Contrats d'accueil pris en premier
au Module 3).

**Découverte de recherche notable** : `/comptabilite` était déjà réservé
dans `security.yaml` depuis le Lot 0
(`{ path: ^/comptabilite, roles: [ROLE_COMPTABILITE, ROLE_DIRECTION] }`,
avec `role_hierarchy` faisant hériter `ROLE_DIRECTION` de
`ROLE_COMPTABILITE`) — **aucune nouvelle règle de sécurité nécessaire**,
`GrilleTarifaireController` est simplement le premier contrôleur à
utiliser ce préfixe. `ROLE_COMPTABILITE` n'était en revanche jamais
utilisé ailleurs dans le code et aucun compte fixture ne l'avait — ajouté
(`comptabilite@creche-test.local` dans `AppFixtures.php`).

Décisions de scope :
- **`Contrat` n'est pas touché** : le lien vers `GrilleTarifaire`
  (pré-remplissage à la création, choix du mode "forfait fixe" vs
  "facturation selon le planning contractuel") est le sujet de b),
  pas de cet incrément — la grille est un catalogue, rien ne le
  consomme encore.
- **`GrilleTarifaire` = catalogue libre** (§7.2.a dit explicitement
  "grille tarifaire libre") : une ligne = libellé + unité
  (horaire/journalier/forfait mensuel/forfait unique) + montant. Les
  "tarifs additionnels" (frais d'inscription, panier repas...) sont de
  simples lignes `FORFAIT_UNIQUE`, pas une structure séparée.
- **Réduction fratrie = un seul pourcentage sur `Etablissement`**
  (`reductionFratriePourcent`, nullable 0-100), pas d'entité dédiée —
  Etablissement est déjà le seul agrégat de config par crèche.
  L'application de la réduction au calcul d'une facture reste à faire
  en b).
- **Mutations journalisées** dès ce premier incrément
  (`JournalAuditService`), cohérent avec la nature financière du
  module, même si aucune des 5 actions à double validation de §7.2.j
  ne concerne la grille elle-même.

**Nouvelles entités** : enum `UniteTarif`, `GrilleTarifaire` (+
repository). **Champ ajouté** : `Etablissement.reductionFratriePourcent`.
**Nouveau `GrilleTarifaireService`** : créer/modifier/activer/désactiver
une ligne, définir la réduction fratrie (rejette une valeur hors 0-100).
**Nouveau `GrilleTarifaireController`** sous
`/comptabilite/grille-tarifaire`. **Nouveau layout**
`templates/comptabilite/_layout.html.twig`, premier écran de ce
préfixe, prêt à accueillir les futurs écrans (factures, paiements,
avances, trésorerie, export).

Vérifié manuellement (serveur PHP local + curl, 4 rôles) : connexion
avec le nouveau compte Comptabilité, accès à l'écran ; Direction y
accède aussi (héritage de rôle, sans règle de sécurité dédiée) ;
création de 3 lignes (horaire, forfait mensuel, forfait unique) ;
édition d'une ligne (montant + désactivation en un seul envoi) ;
configuration d'une réduction fratrie à 10%, rejet d'une valeur à 150%
avec message d'erreur clair et valeur précédente conservée en base ;
403 pour un éducateur et pour un parent.

`php bin/phpunit` : 103 tests, 252 assertions, tous verts (1 nouveau
fichier de tests, 6 cas). `doctrine:schema:validate` : mapping et base
synchronisés. `lint:twig`/`lint:container` : OK.

## Détail de b) Génération des factures (livré le 2026-08-04)

Deuxième incrément du Module 4. Bibliothèque PDF choisie avec
l'utilisateur : **Dompdf** (pure PHP, MIT, pas de binaire externe).

**`Contrat` évolue** (deux nouveaux champs immuables, comme
`tarifConvenu`/`libelleTarif` — un avenant crée un nouveau `Contrat`,
il ne mute pas l'ancien) :
- `modeFacturation` (`ModeFacturation` : `FORFAIT_FIXE`/
  `PLANNING_CONTRACTUEL`)
- `ligneTarifaire` (`?GrilleTarifaire`, requis seulement en mode
  planning contractuel)

Migration : colonnes ajoutées avec un défaut transitoire
(`DEFAULT 'forfait_fixe'`, retiré juste après) pour backfiller les
contrats déjà existants — cohérent avec leur `tarifConvenu` déjà traité
comme un montant forfaitaire jusqu'ici.

**Découverte importante** : `/comptabilite` (`ROLE_COMPTABILITE`+
`ROLE_DIRECTION` par héritage) suffisait pour le nouveau contrôleur,
mais Comptabilité n'avait **aucun moyen d'atteindre un enfant**
(`/administration/*`, y compris la liste des dossiers, reste
ROLE_DIRECTION seul, sans héritage inverse). Ajout d'un petit
`ComptabiliteDossierEnfantController` sous `/comptabilite/enfants` —
sans lui, un compte Comptabilité seul n'aurait jamais pu utiliser
l'écran de facturation.

Décisions de scope :
- **Réduction fratrie non appliquée automatiquement** — la
  configuration existe (a) mais "qu'est-ce qu'une famille / quel enfant
  compte comme le 2e" reste une question ouverte
  (`decisions-a-prendre.md`).
- **Génération manuelle, une facture à la fois** — pas de génération en
  masse ni planifiée (aucune infra de tâches planifiées dans le
  projet).
- **Pas d'annulation** (§7.2.j, double validation pas encore
  modélisée) ni **de suivi de statut de paiement** (§7.2.d, sous-item
  séparé) dans cet incrément.
- **Calcul en flottant + `sprintf('%.2f', ...)`**, pas `bcmath` —
  premier calcul décimal de l'app, cohérent avec la simplicité du
  reste du code.
- **Une facture exige un payeur identifié**
  (`EnfantResponsableLegal.estPersonnePayeuse`) — nouvelle exception
  `AucunPayeurDefiniException` plutôt qu'une facture sans destinataire.
- **Un montant "planning contractuel" à 0€ est refusé** (période hors
  validité du contrat) — message clair plutôt qu'une facture à 0€.

**Nouveau `FactureService`** : calcule le montant (forfait fixe =
`tarifConvenu` tel quel, quel que soit le nombre de jours ; planning
contractuel = jours contractuels dans la période — clippée à la
validité du contrat — × tarif de la ligne, horaire ou journalier),
détermine le(s) payeur(s), génère le PDF (rendu Twig →
`comptabilite/facture_pdf.html.twig` → Dompdf), le stocke via
`StockagePhotoInterface` (même abstraction que documents/photos),
numérote (`FAC-{année}-{rang}`, rang par établissement/année basé sur
la date d'émission), journalise. **Confirmé par construction et par
test** : ce Service ne lit jamais `Presence` — seuls les
jours/heures *prévus* au contrat entrent dans le calcul (§7.2.b "la
présence physique réelle n'a aucune incidence").

**Nouveaux contrôleurs** : `FactureController`
(`/comptabilite/enfant/{id}/facture`), `ComptabiliteDossierEnfantController`
(`/comptabilite/enfants`). **`EspaceParentController`** étendu de 2
routes (`/factures`, `/factures/{id}/telecharger`, mêmes garde
`ParentEnfantVoter` que le reste du contrôleur). Téléchargement en
flux direct authentifié (comme `DocumentAdministratifController`), pas
de lien signé public (pas nécessaire, à la différence des photos).

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Direction/Parent/Éducateur) : Comptabilité atteint la liste des
enfants puis la fiche facture ; génère une facture forfait fixe
(montant = tarif convenu du contrat, vérifié) ; création d'une ligne
tarifaire journalière (1000/jour) et d'un avenant en mode planning
contractuel (lundi/mardi/mercredi) sur le contrat d'Aminata ; génère
une facture planning contractuel sur une semaine — **montant calculé
3000,00 vérifié à la main (3 jours contractuels × 1000)** ; PDF
téléchargé et son contenu texte confirmé (`pdftotext` : numéro,
période, mode, enfant, payeur, montant tous présents) ; génération sans
payeur défini refusée avec message clair, aucune facture parasite
créée ; le parent voit ses 2 factures et télécharge un PDF
**byte-identique** à celui vu côté Comptabilité ; 403 pour le parent
d'un autre enfant ; 403 pour un éducateur sur les écrans Comptabilité.

`php bin/phpunit` : 113 tests, 270 assertions, tous verts (1 nouveau
fichier de tests, 10 cas, + `ContratServiceTest` mis à jour).
`doctrine:schema:validate` : mapping et base synchronisés.
`lint:twig`/`lint:container` : OK.

## Détail de d) Suivi des paiements (livré le 2026-08-04)

Troisième incrément du Module 4. `Facture` restait volontairement
minimale (son docblock le disait) : ce sous-item comble le manque en
ajoutant l'enregistrement des paiements et le statut dérivé.

Décisions de scope :
- **Pas de notion d'échéance/date limite ni de statut "en retard"** —
  le cahier des charges ne précise aucun délai de paiement standard ;
  en décider un aurait été une hypothèse métier non demandée. Statut
  limité à impayée / partiellement payée / payée.
- **Statut calculé, jamais stocké** (`PaiementService::statut()`,
  compare `SUM(Paiement.montant)` à `Facture.montant`) — cohérent avec
  l'esprit du reste de l'app, pas d'état dupliqué qui pourrait
  diverger.
- **`Paiement` et `Relance` sont append-only**, comme `Facture` — pas
  de correction/suppression d'un paiement mal saisi (nécessiterait le
  mécanisme de séparation saisie/validation du §7.2.j, pas encore
  modélisé). Pas de plafond sur le cumul (un trop-perçu reste accepté,
  statut "payée").
- **Relance = email aux payeurs**, réutilise
  `NotificationParentMailer::notifierResponsables()` tel quel
  (best-effort déjà éprouvé au Lot 2). Refusée si la facture est déjà
  intégralement payée (vérifié en Service, pas seulement caché côté
  vue).
- **Vue d'ensemble par établissement** (`/comptabilite/paiements`,
  nouveau `SuiviPaiementsController`, filtrable par statut) répond à
  "état des factures" (§7.2.d) ; les actions (enregistrer un paiement,
  envoyer une relance) restent sur la fiche facture par enfant déjà
  existante (`comptabilite/facture.html.twig`), cohérent avec
  "historique par enfant/famille".
- **Statut aussi affiché côté parent**, en lecture seule
  (`parents/factures.html.twig`) — extension légère de ce que b) avait
  déjà mis à disposition, pas de nouvelle route.

**Nouvelles entités** : `Paiement` (montant, date, mode via l'enum
`ModePaiement`, qui a enregistré, commentaire optionnel), `Relance`
(qui, quand). **Nouveaux services** : `PaiementService`
(enregistrer/sommePayee/montantRestant/statut/historique),
`RelanceService` (envoyer/historique, dépend de `PaiementService` pour
le refus si déjà payée). **Nouveau contrôleur**
`SuiviPaiementsController` (`/comptabilite/paiements`).
`FactureController` étendu de 2 routes (`/{id}/paiement`,
`/{id}/relance`).

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Éducateur/Parent) : vue d'ensemble liste les 2 factures existantes en
"Impayée" ; paiement partiel (20000/50000) → statut "Partiellement
payée", restant dû 30000.00 correct, visible sur les deux écrans ;
second paiement complétant le montant → statut "Payée", formulaires de
paiement/relance disparaissent ; paiement à 0 et paiement négatif
rejetés avec message clair, aucune ligne parasite en base ; relance
envoyée sur la facture encore impayée, entrée créée et affichée
("Dernière relance envoyée le...") ; bouton de relance absent une fois
la facture payée (et le refus est vérifié au niveau Service par test
unitaire, pas seulement par l'UI) ; le parent voit le statut de ses 2
factures en lecture seule ; 403 pour un éducateur sur
`/comptabilite/paiements` et sur la fiche facture.

`php bin/phpunit` : 126 tests, 303 assertions, tous verts (2 nouveaux
fichiers de tests, 13 cas). `doctrine:schema:validate` : mapping et
base synchronisés. `lint:twig`/`lint:container` : OK.

## Détail de c) Retrait de l'enfant en cours de contrat (livré le 2026-08-04)

Quatrième incrément du Module 4. La résiliation existait déjà depuis le
Lot 3 (`ContratService::resilier()`, commentaire explicite "sans calcul
de solde — Module 4") : cet incrément ne construit pas de nouveau
workflow de résiliation, il comble exactement ce trou.

Décisions de scope :
- **"Proposition de solde" = balance déjà facturé/payé sur ce contrat**
  (`total payé − total facturé`, via `Facture.contrat`/
  `PaiementService::sommePayee()`), pas une reconstruction théorique du
  montant "dû" pour une période partielle — ambigu en mode
  `FORFAIT_FIXE` (§7.2.b ne donne aucune règle de proratisation pour
  une sortie en cours de période). Positif = remboursement dû ; négatif
  ou nul = famille encore débitrice.
- **Frais de préavis non remboursables = nouveau champ optionnel sur
  `Contrat`** (`fraisPreavisNonRemboursable`, immuable comme
  `tarifConvenu`, fixé à la création/avenant — "si prévus au contrat").
  Réduit uniquement un remboursement dû, plafonné à 0 (ne crée jamais
  de dette supplémentaire) : `solde = brut > 0 ? max(0, brut −
  préavis) : brut`.
- **Calcul à la demande, jamais stocké** (`RetraitService::calculerSolde()`),
  même principe que `PaiementService::statut()` (d).
- **Aucune transaction créée automatiquement** — "la validation et
  l'exécution du remboursement restent une action manuelle" (§7.2.c) :
  le calcul est purement informatif. Exécuter réellement un
  remboursement reste hors scope (limite documentée dans
  `backlog-v2.md`, à côté de la limite équivalente sur la correction de
  paiement, d).
- **Affiché côté Comptabilité** (`comptabilite/facture.html.twig`,
  section "Solde de retrait"), pas Direction — donnée financière qui
  s'appuie sur `Facture`/`Paiement`. Un récapitulatif minimal du
  contrat clos (dates, tarif, préavis) est ajouté côté
  `administration/contrat.html.twig` (trou d'UI comblé au passage :
  avant cet incrément, un contrat résilié faisait directement retomber
  l'écran sur le formulaire "nouveau contrat" sans aucun récapitulatif).

**Nouveau `RetraitService::calculerSolde(Contrat): array`** (total
facturé/payé/préavis/solde). `FactureRepository` gagne
`findPourContrat(Contrat)`. `FactureController::afficher()` détecte si
le dernier contrat de la chaîne (`ContratRepository::findPourEnfant()[0]`)
est clos (`!actif && dateFin !== null`) et calcule le solde le cas
échéant.

Vérifié manuellement (serveur PHP local + curl, comptes Direction/
Comptabilité/Éducateur) : avenant avec préavis de 5000 créé, facture de
45000 générée dessus, paiement de 60000 enregistré (trop-perçu
volontaire) ; résiliation du contrat ; écran Direction affiche le
récapitulatif "Contrat clos" avec le formulaire de nouveau contrat
toujours disponible en dessous ; écran Comptabilité affiche "Solde de
retrait" : total facturé 45000.00, total payé 60000.00, préavis
5000.00, **solde 10000.00 — cohérent avec le calcul à la main**
(60000 − 45000 − 5000) ; aucune section solde pour un enfant sans
contrat ; 403 pour un éducateur sur les deux écrans (non-régression).

`php bin/phpunit` : 131 tests, 315 assertions, tous verts (1 nouveau
fichier de tests, 5 cas, + `ContratServiceTest`/`FactureServiceTest`/
`PaiementServiceTest`/`RelanceServiceTest` mis à jour). Migration sans
souci de backfill (colonne nullable). `doctrine:schema:validate` :
mapping et base synchronisés. `lint:twig`/`lint:container` : OK.

## Détail de i) Comptabilité générale — export (livré le 2026-08-04)

Cinquième incrément du Module 4.

Décisions de scope :
- **CSV uniquement, pas de bibliothèque Excel** — cohérent avec le
  choix Dompdf (simple, robuste, zéro dépendance à arbitrer) ; un
  `.csv` s'ouvre nativement dans Excel. BOM UTF-8 + séparateur `;`
  (locale française : la virgule y est le séparateur décimal, sans
  quoi montants et accents casseraient l'ouverture).
- **Deux exports séparés** (factures, paiements) — grains différents
  (une facture a plusieurs paiements), pas de fusion pour rester
  simple.
- **Filtre par plage de dates optionnelle**, sur `dateGeneration`
  (factures) / `datePaiement` (paiements).
- **"Attestations si nécessaire" hors scope** — une ligne sans
  élaboration dans le cahier des charges (aucun modèle spécifié),
  même traitement que "modèles de documents" (Module 3, f) — cf.
  `backlog-v2.md`.
- **Export journalisé** (`ACTION_CONSULTATION`), comme
  `PhotoDownloadController` — expose les données financières de toutes
  les familles d'un coup, plus sensible qu'une consultation unitaire.

**Bug trouvé et corrigé pendant la vérification manuelle** :
`FactureRepository::findParEtablissement()` filtrait `dateGeneration <=
:fin` avec `$fin` reçu à minuit (simple date, pas d'heure) — un filtre
"jusqu'au 04/08" excluait à tort toutes les factures générées après
00:00 ce jour-là (`dateGeneration` est un `datetime`, pas une `date`).
Corrigé en bornant `< :fin` avec `$fin` décalé de +1 jour (borne
exclusive au jour suivant, même principe que
`compterPourEtablissementEtAnnee()`), pour inclure toute la journée de
`$fin`. Invisible des tests unitaires (qui mockent le repository), donc
seulement détecté en E2E — reconfirmé manuellement après correction.

**Nouveau `ExportComptableService`** : `exporterFactures()`/
`exporterPaiements()`, génèrent le CSV (`fputcsv` sur `php://temp`) et
journalisent. **Nouveau `ExportComptableController`** sous
`/comptabilite/export` (3 routes GET, pas de CSRF nécessaire).
`FactureRepository::findParEtablissement()` et nouvelle
`PaiementRepository::findParEtablissement()` gagnent un filtre de
dates optionnel.

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Éducateur) : écran `/comptabilite/export` affiche les 2 formulaires ;
`factures.csv` sans filtre → 3 factures, BOM présent, accents et
montants corrects ; filtre `date_debut=date_fin=aujourd'hui` → les 3
factures incluses (après correction du bug ci-dessus) ; filtre sur une
date antérieure → 0 ligne ; `paiements.csv` → 3 paiements avec le bon
numéro de facture ; `journal_audit` incrémenté de 2 (une entrée
`Facture`/`Paiement`, `action=consultation`) après les deux
téléchargements ; 403 pour un éducateur sur l'écran et les 2 routes
CSV.

`php bin/phpunit` : 136 tests, 337 assertions, tous verts (1 nouveau
fichier de tests, 5 cas). Aucune migration nécessaire.
`doctrine:schema:validate` : mapping et base synchronisés.
`lint:twig`/`lint:container` : OK.

## Détail de f) Avances et apports du fondateur (livré le 2026-08-04)

Sixième incrément du Module 4. Premier incrément qui construit un vrai
workflow à double intervenant (saisie ≠ validation, §7.2.j) — jusqu'ici
tout (facture, paiement, relance) se faisait en un seul geste.

Décisions de scope :
- **"Statut décidé à la saisie" (§7.2.f) lu comme "la catégorie
  choisie est définitive"**, pas comme un champ distinct — cohérent
  avec la phrase suivante du cahier des charges ("le logiciel
  enregistre la catégorie, il ne qualifie jamais juridiquement le
  mouvement"). `categorie` est donc immuable, fixée à la saisie.
- **Nouvel enum générique `StatutValidationFinanciere`**
  (`EN_ATTENTE`/`VALIDE`/`REFUSE`), pas de préfixe métier : première
  brique de l'infrastructure §7.2.j, pensée pour être réutilisée par
  de futurs incréments (annuler une facture, valider une dépense,
  exécuter un remboursement proposé par c).
- **Pas de contrôle "personne différente" (saisie ≠ validation)** —
  seulement un contrôle de rôle (`ROLE_DIRECTION`) sur la validation :
  le cahier des charges dit "Validation Direction (ou le fondateur si
  distinct)", sans exiger que ce soit une personne différente de celle
  qui a saisi (contrairement à `AdministrationMedicament::estMemeCompte()`,
  §5.2.h, qui l'exige explicitement).
- **Garde de rôle en Contrôleur, pas en `security.yaml`** —
  `ROLE_DIRECTION` hérite de `ROLE_COMPTABILITE`, donc
  `^/comptabilite` seul ne peut jamais distinguer "saisi par
  Comptabilité" de "validé par Direction". `denyAccessUnlessGranted('ROLE_DIRECTION')`
  ajouté explicitement sur les 2 routes de validation, même principe
  que `PresenceController::validerPersonneAutorisee` (§4.2.e) —
  **vérifié en E2E qu'un compte Comptabilité reçoit 403 même en
  forgeant directement la requête**, pas seulement caché côté
  interface.
- **Validation de pièce justificative dupliquée depuis
  `DocumentAdministratifService`** (taille max 8 Mo, détection MIME
  réelle via `finfo` contre PDF/JPEG/PNG) plutôt que refactorée en
  service partagé — hors périmètre de cet incrément.
- **Pas d'agrégation de trésorerie** — h) Suivi de trésorerie
  consommera ces mouvements plus tard (cf. `backlog-v2.md`).

**Nouveaux enums** `CategorieMouvementFondateur` (5 catégories),
`StatutValidationFinanciere`. **Nouvelle entité `MouvementFondateur`** :
champs de saisie immuables (catégorie, montant, description, pièce
justificative), `statut` mutable uniquement via
`marquerValide()`/`marquerRefuse()` sur l'entité (jamais un setter
direct), `valideePar`/`dateValidation`/`motifRefus` stockés directement
(même choix que `AdministrationMedicament::marquerVerifie()`, pas
seulement dans `JournalAudit`). **Nouveau `MouvementFondateurService`** :
`saisir()`/`valider()`/`refuser()` (rejette un mouvement déjà traité)/
`historique()`/`contenuPieceJustificative()`. **Nouveau
`MouvementFondateurController`** sous `/comptabilite/avances`.

**Bug trouvé et corrigé pendant la vérification manuelle** : la route
de téléchargement de la pièce justificative ne fixait pas
`Content-Type`, retombant sur `text/html` par défaut au lieu du type
réel du fichier. Corrigé en reprenant exactement le pattern de
`FicheEnfantAdminController::telechargerDocument` (détection `finfo` +
`HeaderUtils::makeDisposition`), revérifié après correction
(`Content-Type: image/png` correct, contenu téléchargé byte-identique
au fichier envoyé).

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Direction/Éducateur) : Comptabilité saisit un mouvement (don, pièce
PNG factice) → "En attente", aucun bouton valider/refuser visible pour
ce compte ; Direction voit les boutons, valide → "Validé",
`valideePar`/date affichés, boutons disparaissent ; second mouvement
refusé sans motif → rejeté (statut inchangé), refusé avec motif →
"Refusé", motif affiché ; téléchargement de la pièce justificative
byte-identique au fichier envoyé, `Content-Type` correct ; 403 pour un
éducateur sur `/comptabilite/avances` ; **403 pour un compte
Comptabilité tentant de valider directement, CSRF forgé inclus**
(garde de rôle vérifiée avant même la vérification CSRF).

`php bin/phpunit` : 146 tests, 363 assertions, tous verts (1 nouveau
fichier de tests, 10 cas). `doctrine:schema:validate` : mapping et
base synchronisés. `lint:twig`/`lint:container` : OK.

## Détail de h) Suivi de trésorerie (livré le 2026-08-04)

Septième incrément du Module 4. Aucune notion de "sortant" (dépense)
n'existait encore : h) a d'abord posé `Depense`, dernière ligne non
couverte du tableau §7.2.j (avance fondateur faite en f, remboursement
proposé en c) — puis construit l'agrégation entrants/sortants.

Décisions de scope :
- **`Depense.categorie` = texte libre**, pas d'enum fermé (contrairement
  aux 5 catégories explicites de f) — le cahier des charges ne donne
  aucune taxonomie de dépenses, inventer une liste
  (fournitures/salaires/loyer...) aurait été une hypothèse métier non
  demandée. Même philosophie que `GrilleTarifaire` ("grille tarifaire
  libre", a).
- **Pièce justificative optionnelle pour une dépense** (nullable),
  contrairement à f) où elle est explicitement obligatoire — l'exigence
  du §7.2.f ne s'étend pas à "créer une dépense" (§7.2.j).
- **Validation = personne différente du saisissant, pas un rôle
  particulier** — différence clé avec f) (rôle `ROLE_DIRECTION`
  explicite). Le tableau §7.2.j dit "une autre personne que le
  saisissant" pour la dépense, contre "Direction (ou le fondateur si
  distinct)" pour l'avance fondateur. Vérifié via
  `User::estMemeCompte()` (déjà utilisé par
  `AdministrationMedicamentService::verifier()`, §5.2.h) en Service,
  pas de garde de rôle en Contrôleur cette fois.
- **`StatutValidationFinanciere` réutilisé tel quel** (posé en f)
  volontairement générique) — aucun nouvel enum de statut nécessaire,
  confirme le pari fait à l'incrément précédent.
- **Trésorerie = seulement les mouvements de cash réels et validés** :
  entrants = tous les `Paiement` + `MouvementFondateur` `VALIDE` (hors
  `DEPENSE_PERSONNELLE_A_REMBOURSER`, qui est une reconnaissance de
  dette envers le fondateur, pas un encaissement réel) ; sortants =
  `Depense` `VALIDE`. Les mouvements `EN_ATTENTE`/`REFUSE` n'entrent
  jamais dans le solde.
- **Agrégation en PHP**, pas de `GROUP BY` SQL — cohérent avec
  `RetraitService` (c), volumétrie crèche mono-site suffisamment
  faible.

**Nouvelle entité `Depense`** (+ repository) : mêmes principes
d'immuabilité/statut mutable que `MouvementFondateur`. **Nouveau
`DepenseService`** : `saisir()`/`valider()`/`refuser()` (contrôle
"personne différente" avant tout autre contrôle)/`historique()`/
`contenuPieceJustificative()` (peut renvoyer `null`). **Nouveau
`TresorerieService::calculerVueDEnsemble()`** : entrants/sortants par
catégorie + total + solde. **Nouveaux contrôleurs**
`DepenseController` (`/comptabilite/depenses`, sans garde de rôle sur
la validation) et `TresorerieController` (`/comptabilite/tresorerie`,
lecture seule).

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Direction/Éducateur) : dépense saisie par un compte Comptabilité ;
tentative de validation par ce même compte → rejetée avec message
clair ; validation par un compte Direction différent → "Validé" ;
`/comptabilite/tresorerie` : entrants = paiements existants (110000)
+ don validé (50000) = 160000 (l'avance fondateur refusée n'y figure
pas), sortants = dépense validée (15000), **solde 145000 — cohérent
avec un calcul à la main** ; téléchargement de pièce justificative
absente → 404 propre ; 403 pour un éducateur sur les 2 écrans.

`php bin/phpunit` : 161 tests, 397 assertions, tous verts (2 nouveaux
fichiers de tests, 15 cas). `doctrine:schema:validate` : mapping et
base synchronisés. `lint:twig`/`lint:container` : OK.

## Détail de j) Effectuer un remboursement (livré le 2026-08-04)

Huitième incrément du Module 4 — dernière ligne du tableau §7.2.j.
Ferme la boucle ouverte par c) : `RetraitService::calculerSolde()`
calculait déjà une *proposition* de solde ("la validation et
l'exécution du remboursement restent une action manuelle"), affichée
dans la section "Solde de retrait" de la fiche facture, mais rien
n'enregistrait qu'un remboursement avait réellement été exécuté.

Décisions de scope :
- **Régime de validation = rôle (`ROLE_DIRECTION`), pas "personne
  différente"** — le tableau §7.2.j dit "Validation : Direction" pour
  le remboursement, identique à l'avance fondateur (f), à la
  différence de la dépense (h, "une autre personne que le
  saisissant"). Réutilise `denyAccessUnlessGranted('ROLE_DIRECTION')`
  (f), pas `User::estMemeCompte()` (h).
- **`Remboursement` lié à `Contrat`**, pas `Facture`/`Enfant` — c'est
  le grain déjà utilisé par `RetraitService::calculerSolde(Contrat)`.
- **Montant saisi manuellement, jamais verrouillé sur le solde
  calculé** — le solde reste une proposition (§7.2.c) ; Comptabilité
  saisit le montant réellement remboursé (peut différer en pratique :
  remboursement partiel, ajustement).
- **Pas de contrainte "contrat clos"/"solde positif" au niveau
  Service** — seul le point d'entrée dans l'UI (section "Solde de
  retrait", déjà conditionnée à un contrat clos) restreint l'usage
  réel ; sur-contraindre le Service aurait interdit des cas légitimes
  non prévus (ex. correction d'un trop-perçu hors contexte de retrait)
  sans besoin avéré.
- **Intégré à `FactureController`**, pas de nouveau contrôleur — même
  raisonnement que d) (paiement/relance) : le remboursement se
  déclenche depuis l'écran où le solde est déjà visible.
- **`StatutValidationFinanciere` réutilisé une troisième fois** sans
  aucune modification — confirme que l'enum posé en f) tient
  pleinement son rôle de brique générique §7.2.j.

**Nouvelle entité `Remboursement`** (+ repository), même forme que
`MouvementFondateur`/`Depense`. **Nouveau `RemboursementService`** :
`saisir()`/`valider()`/`refuser()`/`historiquePourContrat()`.
**`TresorerieService`** (édition) : les remboursements validés
apparaissent désormais comme sortants, catégorie "Remboursements".
**`FactureController`** (édition) : 3 nouvelles routes sous
`/comptabilite/enfant/{id}/facture/remboursement/*`, section "Solde de
retrait" enrichie d'un historique + formulaire de saisie.

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Direction/Éducateur), en reprenant le contrat déjà clos de c) (solde
10000.00) : remboursement de 10000 saisi par Comptabilité → "En
attente" ; tentative de validation par ce même compte Comptabilité →
**403 avant même la vérification CSRF** (garde de rôle, comme f) ;
validation par Direction → "Validé" ; `/comptabilite/tresorerie` :
sortant "Remboursements : 10000.00" apparaît, **solde recalculé
135000.00 — cohérent avec le solde précédent (145000) moins le
remboursement (10000)** ; second remboursement refusé par Direction
avec motif → "Refusé" ; 403 pour un éducateur sur les deux écrans.

`php bin/phpunit` : 170 tests, 416 assertions, tous verts (1 nouveau
fichier de tests, 7 cas, + `TresorerieServiceTest` étendu d'1 cas).
`doctrine:schema:validate` : mapping et base synchronisés.
`lint:twig`/`lint:container` : OK.

**Correction a posteriori** : §7.2.j compte en réalité **5 actions**
(le tableau complet n'avait pas été relu en entier lors de cet
incrément), pas 3 comme annoncé ici initialement — "Annuler une
facture" et "Modifier un paiement clôturé" restaient non couvertes.
Cf. section dédiée plus bas pour la première ; la seconde reste dans
`documentation/backlog-v2.md`. Les deux régimes de validation
distincts du tableau (rôle vs personne différente) sont en tout cas
déjà couverts et éprouvés en E2E, y compris le contournement (403
avant CSRF).

## Détail de g) Cantine (livré le 2026-08-05)

Neuvième incrément du Module 4. L'enregistrement de la consommation
existait déjà en entier depuis le Module 2 (`Repas.origineRepas`/
`statutConsommationCantine`, avec un docblock explicite "pour que la
facturation cantine du Lot 4 puisse s'appuyer dessus sans backfill") :
g) ne portait donc que le volet financier — transformer les repas
`CANTINE_CRECHE`/`CONSOMME` d'une période en un montant facturable.

**Gap découvert en cours de vérification manuelle, corrigé dans le même
incrément** : `SuiviQuotidienService::ajouterRepas()`/
`ajouterRepasGroupe()` ne renseignaient jamais
`statutConsommationCantine` (toujours `NULL`) — la 2ᵉ puce de §7.2.g
("gestion des repas annulés/offerts") n'avait donc aucun moyen d'être
utilisée : le comptage facturable aurait toujours été nul. Corrigé en
2 volets, restés dans le périmètre naturel de g) plutôt que reportés :
- Un repas `CANTINE_CRECHE` est désormais présumé `CONSOMME` à la
  saisie (`SuiviQuotidienService::initialiserStatutConsommationCantine()`).
- Nouvelle méthode `SuiviQuotidienService::corrigerStatutConsommationCantine()`
  + route `POST /equipe/enfant/{id}/repas/{repasId}/statut-cantine`
  (`FicheEnfantController`) pour requalifier `ANNULE`/`OFFERT` a
  posteriori — traçé via `JournalAudit`, pas de nouveau champ dédié
  (même principe que les autres corrections du suivi quotidien, §5.2.b).
  Formulaire ajouté sur `equipe/fiche_enfant.html.twig`, visible pour
  chaque repas d'origine cantine du jour.

Décisions de scope :
- **Tarif cantine = une ligne `GrilleTarifaire` d'un nouveau
  `UniteTarif::REPAS`** ("par repas consommé"), pas un champ dédié sur
  `Etablissement` — cohérent avec "grille tarifaire libre" (a). Une
  seule ligne active de cette unité est attendue par établissement.
- **Nouveau troisième cas `ModeFacturation::CANTINE`** — une facture
  cantine n'est ni un forfait fixe contractuel ni un calcul sur le
  planning contractuel ; n'affecte pas le calcul contractuel existant
  (`calculerMontant()`, jamais appelé pour la cantine).
- **`FactureService::genererPourCantine()`**, méthode séparée de
  `genererPourContrat()` — le bloc commun (numérotation, PDF, stockage,
  persistance, journalisation) a été extrait dans une méthode privée
  partagée `finaliserFacture()` (refactor sans changement de
  comportement, **113 tests existants de facturation toujours verts
  après coup**, confirmant la non-régression). Le contrat de l'enfant
  n'est utilisé que pour la FK de traçabilité obligatoire sur
  `Facture`, pas pour le calcul.
- **Aucun repas consommé ou aucune ligne tarifaire "par repas" → rejet
  clair**, pas de facture à 0€ silencieuse (même principe qu'en b).
- **Écran intégré à `FactureController`/`comptabilite/facture.html.twig`**
  (nouvelle section "Facturation cantine"), pas de nouveau contrôleur.

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Éducateur) : ligne tarifaire "Panier repas" créée (1500/repas) ;
3 repas `CANTINE_CRECHE` consommés + 1 repas apporté (exclu) saisis via
l'écran équipe ; facture cantine générée sur la période → **4500.00
(3 × 1500), vérifié dans le PDF téléchargé** ; correction d'un repas
en "Annulé" via la nouvelle route → statut mis à jour, tracé dans
`JournalAudit` ; génération sur une période sans repas consommé →
rejetée avec message clair ; ligne tarifaire désactivée temporairement
→ génération rejetée avec message clair ; 403 pour un éducateur sur
l'écran de facturation (non-régression).

`php bin/phpunit` : 176 tests, 432 assertions, tous verts (7 nouveaux
cas dans `FactureServiceTest`, refactor non-régressif). Aucune
migration nécessaire (nouveaux cas d'enum PHP, colonnes déjà
`VARCHAR`). `doctrine:schema:validate` : mapping et base synchronisés.
`lint:twig`/`lint:container` : OK.

## Détail de j) Annuler une facture — avoir/contre-écriture (livré le 2026-08-05)

Dixième incrément du Module 4. En relisant le tableau complet du
§7.2.j (5 lignes), corrige l'annonce erronée "j complet (3/3)" faite à
l'incrément précédent — il en manquait deux : "Annuler une facture"
(traitée ici) et "Modifier un paiement clôturé" (reste en backlog).

Décisions de scope :
- **`AvoirFacture` n'a pas de champ `montant` propre** — un avoir
  annule la facture entière, le montant se lit via
  `avoir.getFacture()->getMontant()`. Ajouter un montant séparé aurait
  introduit un état potentiellement incohérent sans besoin identifié.
- **Numérotation continue propre à l'avoir** (`AV-{année}-{rang}`),
  distincte de celle des factures — ne consomme jamais un numéro de
  `Facture` (cohérent avec le contrôle "numérotation continue des
  factures" du §7.2.j : la séquence des factures reste ininterrompue
  même après annulation).
- **Un seul avoir actif par facture** (`EN_ATTENTE` ou `VALIDE`) — un
  avoir refusé ne bloque pas une nouvelle demande, vérifié en E2E.
- **Aucune contrainte de statut de paiement pour demander une
  annulation** — une facture déjà payée reste annulable (vérifié en
  E2E sur FAC-2026-0001, déjà "Payée") ; un éventuel remboursement
  suite à l'annulation se fait séparément via `Remboursement` (déjà
  livré), l'avoir ne déclenche aucun mouvement de trésorerie
  automatique (vérifié : `/comptabilite/tresorerie` inchangée après
  validation d'un avoir).
- **Régime de validation = rôle (`ROLE_DIRECTION`)**, comme l'avance
  fondateur (f) — le tableau dit "Direction", pas "une autre personne
  que le saisissant" (régime de la dépense, h).
- **Intégré à `FactureController`/`comptabilite/facture.html.twig`**,
  pas de nouveau contrôleur — badge "ANNULÉE" remplace les formulaires
  paiement/relance une fois l'avoir validé.

**Nouvelle entité `AvoirFacture`** (+ repository), même forme que
`MouvementFondateur`/`Depense`/`Remboursement`. **Nouveau
`AvoirFactureService`** : `demander()`/`valider()`/`refuser()`/
`estAnnulee()`/`historiquePourFacture()`. **`FactureController`**
(édition) : 3 nouvelles routes sous
`/comptabilite/enfant/{id}/facture/{id}/annulation/*`.

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Direction/Éducateur) : demande d'annulation sur une facture déjà payée
→ "En attente", formulaire de nouvelle demande masqué ; **403 pour
Comptabilité tentant de valider directement** (garde de rôle, comme
f) ; validation par Direction → badge "ANNULÉE" affiché, formulaires
paiement/relance disparus ; second avoir refusé par Direction avec
motif → **une nouvelle demande redevient possible** (le refus ne
bloque pas indéfiniment) ; `SELECT COUNT(*) FROM paiement` et
`/comptabilite/tresorerie` inchangés après l'annulation (110000.00 en
entrants paiements, comme avant) ; 403 pour un éducateur.

`php bin/phpunit` : 189 tests, 454 assertions, tous verts (1 nouveau
fichier de tests, 13 cas). `doctrine:schema:validate` : mapping et
base synchronisés. `lint:twig`/`lint:container` : OK.

## Détail de j) Modifier un paiement clôturé — écriture corrective (livré le 2026-08-05)

Onzième incrément du Module 4. Dernière ligne du tableau §7.2.j —
**§7.2.j est désormais complet (5/5)**.

Décisions de scope :
- **"Clôturé" interprété comme "déjà enregistré"** — `Paiement` n'a
  aucune notion de statut brouillon/clôturé distincte ; tout paiement
  existant est par construction "clôturé" dès sa création. Pas de
  nouveau champ de statut ajouté sur `Paiement`.
- **Nouvelle entité `CorrectionPaiement`**, `Paiement` reste
  append-only (décision déjà actée en d) — jamais modifié ni supprimé,
  toujours visible tel quel dans l'historique.
- **`montantCorrectif` signé** (positif ou négatif), contrairement à
  `Paiement.montant` toujours positif.
- **Rejet si le correctif ramènerait le total payé sous zéro** —
  garde-fou simple, vérifié en E2E.
- **Plusieurs corrections possibles sur le même paiement** — pas de
  contrainte "une seule active" comme pour `AvoirFacture` (corriger
  peut légitimement arriver plusieurs fois, contrairement à annuler
  qui est une action binaire).
- **`ROLE_DIRECTION`** comme validateur (le tableau ne nomme pas
  explicitement de rôle pour cette ligne, contrairement aux 3
  précédentes toutes "Direction") — choix par cohérence avec la
  majorité.
- **`PaiementService::sommePayee()` étendu**, point d'agrégation
  unique déjà utilisé par `montantRestant()`, `statut()` et
  `RetraitService::calculerSolde()` (c) — une correction validée s'y
  propage automatiquement partout, sans dupliquer la logique.

**Nouvelle entité `CorrectionPaiement`** (+ repository), même forme
que `Remboursement`/`AvoirFacture`. **Nouveau
`CorrectionPaiementService`** : `demander()`/`valider()`/`refuser()`/
`historiquePourPaiement()`. **`PaiementService::sommePayee()`**
(édition) : `brut (Paiement) + corrections validées`.
**`FactureController`** (édition) : 3 nouvelles routes, `paiements`
de chaque ligne d'historique restructuré en
`['paiement' => ..., 'corrections' => ...]`.

Vérifié manuellement (serveur PHP local + curl, comptes Comptabilité/
Direction/Éducateur) : correction de -5000 sur un paiement de 20000 →
"En attente" ; **403 pour Comptabilité tentant de valider
directement** ; validation par Direction → paiement d'origine
inchangé dans l'historique, correction affichée séparément ;
**calcul vérifié à la main sur deux corrections cumulées** (60000 −
15000 − 20000 = 25000 payé sur 45000 → "Partiellement payée", restant
dû 20000.00, exact) ; correction excessive (-999999) → rejetée avec
message clair (garde-fou anti-négatif) ; **propagation confirmée
jusqu'au "Solde de retrait" (c)** (total payé du contrat reflète la
correction) ; 403 pour un éducateur.

`php bin/phpunit` : 202 tests, 478 assertions, tous verts (1 nouveau
fichier de tests, 11 cas, + `PaiementServiceTest` étendu de 2 cas).
`doctrine:schema:validate` : mapping et base synchronisés.
`lint:twig`/`lint:container` : OK.

## e) Acomptes & dépôt de garantie — sans objet (confirmé le 2026-08-05)

Dernier sous-item du Module 4. Le cahier des charges ne donne qu'une
ligne non élaborée (« Gestion d'un éventuel acompte à l'inscription »),
sans préciser de mécanisme. Question posée au client avant toute
implémentation : le crèche fonctionne-t-elle avec un acompte (avance
sur facture future) et/ou un dépôt de garantie (caution restituable au
départ) ?

**Réponse du client** : aucun des deux n'existe dans leur pratique
réelle — la crèche facture un **droit d'inscription**, qui fait partie
de la scolarité normale, sans jamais passer par un mécanisme d'avance
ou de caution.

Or le droit d'inscription est déjà pleinement couvert depuis le tout
premier incrément du module : **a) Grille tarifaire** modélise
explicitement les « tarifs additionnels (frais d'inscription,
panier repas...) » comme de simples lignes `FORFAIT_UNIQUE` (cf.
commentaire de `UniteTarif`), facturées via le circuit normal de
**b) Génération des factures**. Aucun développement supplémentaire
n'est donc nécessaire : e) est **sans objet** pour ce client, pas un
trou fonctionnel.

**Module 4 est donc entièrement traité** : 10 sous-items livrés (a,
b, c, d, f, g, h, i, j), e) explicitement sans objet.

## Prochaine étape

Module 4 clos. Passage au module suivant du cahier des charges.
