Aller au contenu
Candidater

Journal des modifications

Les évolutions de Cohez.io, de la plus récente à la plus ancienne.

173 versions publiées.

Versions 41 à 50 sur 173

0.23.1.0 — 2026-08-20

Ajouts

  • Le statut d'adhésion se lit désormais depuis les périodes, à côté du statut historique. Deux nouveaux objets de domaine : l'énumération MembershipState (Active / Upcoming / Expired / None) et le value object MembershipStanding, dont resolve(iterable $periods, CarbonImmutable $on) classe un jeu de périodes non annulées en un état et retient la période qui le justifie. La fonction est pure — $on est un paramètre, aucun now() caché — et totale : elle ne lève jamais, y compris sur des données héritées qui violeraient l'invariant de non-chevauchement. Ce choix est délibéré : la garde d'invariant vit sous verrou dans RecordMembershipPeriodUseCase, pas dans un chemin de lecture, où une exception transformerait une ligne douteuse en page 500. Le départage est total et indifférent à l'ordre (fin la plus lointaine, puis début le plus ancien, une période sans fin l'emportant sur une période bornée) : sans lui, deux périodes de même fin auraient donné deux réponses selon l'ordre de la requête. Un test rejoue toutes les permutations d'un même jeu plutôt qu'un tirage aléatoire, un test d'ordre aléatoire finissant tôt ou tard désactivé comme instable.
  • Deux méthodes de lecture sur MembershipRepositoryInterface : standingFor(string $memberId, string $tenantId, CarbonImmutable $on) et standingForMany(array $memberIds, string $tenantId, CarbonImmutable $on). Aucune méthode existante n'est touchée. standingForMany existe pour une raison mesurée : le plus gros tenant compte 514 adhérents, et une résolution par ligne aurait rouvert un N+1 sur la liste. Son contrat est une seule requête quel que soit le nombre d'identifiants, et zéro requête sur une liste vide ; il rend une entrée pour chaque identifiant demandé, un adhérent inconnu ou hors tenant valant MembershipState::None et non une exception — un statut est une lecture d'affichage, pas une assertion d'existence. Aucun filtre de date n'est poussé en SQL : distinguer Expired de None exige de voir les périodes révolues, qu'un WHERE sur la date aurait fait disparaître, effondrant silencieusement les deux états l'un dans l'autre.
  • La fiche adhérent et la liste exposent l'état dérivé. MemberSectionData porte membershipState, membershipStartDate et membershipEndDate ; la projection de ListMembersQuery porte membershipState et membershipEndDate (la liste n'a pas besoin de la borne basse). Le composable useMemberLabels fournit libellés et variantes : « Adhérent » (success), « Adhésion à venir » (info), « Adhésion expirée » (warning), « Non adhérent » (secondary). Sur la fiche comme sur la liste, l'affichage dérivé est posé à côté de l'affichage historique, jamais à sa place : cette PR est un filet de comparaison visuelle, et les deux peuvent légitimement diverger — le filtre de la liste interroge toujours members.status. La bascule des filtres et le retrait de MemberStatus::EXPIRED sont l'objet de la version suivante.

Notes techniques

  • Garde de pente contre le N+1, falsifiable. ListMembersQueryTest compte les requêtes sur 3 puis 10 adhérents et exige le même total ; EloquentMembershipRepositoryTest fait de même sur 3 puis 12 identifiants. Les deux gardes ont été vérifiées par l'échec : standingForMany réécrit en boucle sur standingFor fait tomber trois tests (Failed asserting that 14 is identical to 7, that 3 is identical to 1). Une assertion de compte qui passerait aussi bien sans l'optimisation ne garde rien. L'assertion préexistante de compte exact passe de 4 à 5 requêtes par page : la cinquième est le lot, une par page et non une par ligne.
  • La casse des clés Inertia est tenue par un test, pas par une convention. Les champs sont sérialisés en camelCase, aligné sur le reste de l'application. Renommer les clés en snake_case fait tomber trois tests à la frontière Inertia — vérifié par mutation.
  • Member::memberships() est une relation Eloquent brute, pas le chemin de lecture du statut. Elle porte toutes les lignes, annulées comprises, et n'est pas eager-loadée par la liste : standingForMany() émet sa propre requête en lot. Son PHPDoc le dit explicitement, pour qu'un futur lecteur ne re-dérive pas le statut hors de MembershipStanding et hors du port.
  • MemberListItem est scindé de MemberDetail côté TypeScript. MemberDetail sert deux charges utiles distinctes — la liste (projection de ListMembersQuery, qui porte les champs dérivés) et le formulaire d'édition (MemberResponse, qui ne les porte pas). Déclarer les champs dérivés comme requis sur MemberDetail aurait fait mentir le type sur la surface d'édition ; MemberListItem extends MemberDetail les ajoute pour la seule surface qui les reçoit.
  • vendor/bin/pint peut casser la frontière hexagonale en silence. Sa règle fully_qualified_strings a transformé une référence PHPDoc {@see \App\Models\Membership} d'un fichier Domain en un véritable use App\Models\Membership;. Corrigé ici en reformulant le docblock, mais aucun test d'architecture ne garde cette frontière dans le dépôt : tout fichier Domain citant une classe Eloquent en PHPDoc subira la même transformation sans rien faire rougir.

0.23.0.0 — 2026-08-18

Ajouts

  • memberships:sync {tenant} [--apply] [--anchor=registered_at|season_start] réconcilie les périodes d'adhésion d'un tenant, saison par saison. Elle remplace memberships:backfill, qui ancrait les périodes sur created_at sans jamais rattacher de saison. Par défaut la commande simule : elle n'écrit rien et annonce ce qu'elle ferait. Simulation et application consomment le même plan (MembershipSyncPlan, produit par la fonction pure SyncMemberMembershipsUseCase::plan()) : le rapport ne peut pas diverger de l'écriture, contrairement à un miroir de dry-run maintenu à la main. Un test de parité exécute les deux modes sur le même décor et vérifie que les compteurs annoncés sont identiques et que l'application produit exactement le delta annoncé. La parité vaut aussi sur la branche d'échec : plan() rejoue la garde de non-chevauchement que l'écriture applique, si bien qu'un adhérent en conflit est compté en échec — et non annoncé comme écrit — dans les deux modes. Un second test l'exerce sur une fixture en conflit, avec la même liste de compteurs attendus.
  • Une pré-vérification refuse le tenant entier avant de toucher la moindre ligne. Trois motifs : une saison inversée (start_date > end_date, nommée dans le message) ; deux saisons du tenant qui se chevauchent ; au moins une adhésion non annulée sans end_date — ligne héritée non bornée, correction manuelle ou import. Ce troisième motif nomme les adhérents porteurs (jusqu'à 20) : sans eux le refus est un mur sans issue. Le critère est la borne manquante, pas members.subscription_type — le message ne préjuge donc pas de la cause. Ce refus survit à la suppression du régime à vie (voir plus bas) : son critère a toujours été la borne, jamais le régime, et memberships.end_date NULL reste un état de schéma atteignable en base alors qu'aucun chemin applicatif ne le produit plus. Le moteur ancré sur la saison ne sait ni prolonger ni annuler une adhésion sans fin ; l'opérateur tranche à la main, puis relance. Deux tests l'épinglent — le refus lui-même sur une ligne héritée, et le fait que l'écouteur ne puisse plus l'armer. Les saisons chevauchantes ne sont pas un détail d'hygiène : elles donnent deux season_id cibles pour une même date, que l'index unique partiel accepte et que la contrainte GiST refuse — la divergence SQLite/PostgreSQL pointerait alors dans le mauvais sens et la CI verte mentirait. Un refus n'écrit rien et sort en échec.
  • Rattrapage des adhérents sans inscription validée, par la seule voie déterministe. Quand aucune inscription ne fournit de cible, la commande cherche la saison dont end_date correspond exactement à membership_expires_at et écrit cette saison pleine avec source = Import. Il n'existe pas de repli sur members.status : un adhérent ACTIVE sans date d'expiration est signalé, jamais deviné. Le deviner aurait imposé de retenir la saison contenant le jour courant, c'est-à-dire de faire dépendre le plan de la date d'exécution — deux passages à deux dates auraient fabriqué deux périodes différentes pour le même adhérent, ce qui ruine la garantie « rejouable » sur laquelle repose le mode d'emploi de migration. Et members.status ne prouve pas l'adhésion : aucun écrivain applicatif ne le rétrograde, MemberStatus::EXPIRED n'étant posé que par les seeders de démonstration. Sur la base de développement (sauvegarde de production anonymisée), le rattrapage résout 6 adhérents (3 club-asso, 2 demo-cholet-waterpolo, 1 demo-epgv-44) et en signale 2 sans saison déductible (1 club-asso, 1 les-fontaines-musicales) — attendu, pas une anomalie. Un adhérent sans aucune cible sort avant la réconciliation : ses lignes héritées sont laissées intactes, jamais annulées faute de cible. Un test dédié rejoue la sélection des cibles sous deux horloges distantes de dix mois et exige le même plan.
  • Garantie que memberships:sync n'est jamais automatisée (tests/Feature/Console/MembershipCommandsNotAutomatedTest.php) : ni clevercloud/post_build.sh, ni pre_build.sh, ni cron.sh, ni cron.json, ni routes/console.php, ni bootstrap/app.php ne la mentionnent, et le planificateur résolu ne l'enregistre pas. Chaque cas vérifie d'abord que le fichier existe : un fichier renommé fait échouer le test au lieu de le rendre vide, et l'assertion sur le planificateur est protégée contre la vacuité (la liste des commandes planifiées doit être non vide).

Modifications

  • La période d'adhésion est ancrée sur la saison de la campagne, plus sur des réglages de tenant. CreateMembershipFromRegistrationListener ne consulte plus MembershipSettings ni MembershipPeriodCalculator : il résout la saison via registration.campaign.season et appelle MembershipPeriod::forSeason(seasonStart:, seasonEnd:, anchor: $event->validatedAt()). La règle est celle arrêtée en conception : fin = saison.fin toujours, début = max(saison.début, instant de validation), et repli sur la saison entière si la validation tombe après la fin de saison. La garde MembershipSettings::isConfigured() disparaît : un tenant sans réglage d'adhésion produisait jusqu'ici un écouteur muet, donc des adhérents validés sans aucune période — la saison suffit désormais à décider. Le season_id et created_by: 'listener' sont transmis à RecordMembershipPeriodUseCase, seul écrivain, dont l'idempotence « premier écrivain gagne » est calée sur la saison : une seconde validation dans la même saison n'écrit rien, même à une date postérieure ; une validation dans une autre saison écrit une seconde ligne.
  • RegistrationValidated transporte l'instant de validation. L'événement porte validatedAt et les six sites de dispatch (CancelRegistrationService, SettleFreeCartService, VersementService, RegistrationTransitionController, HelloAssoPaymentConfirmationService, BackfillCancelledAllocationsCommand) le renseignent — un seul CarbonImmutable::now() hissé par opération, si bien que toutes les inscriptions soldées ensemble partagent le même ancrage. C'est l'événement qui fait autorité, pas l'horloge de l'écouteur : un dispatch différé par la file ou rejoué reproduit la période d'origine au lieu d'en fabriquer une décalée.

Retraits

  • Le régime d'adhésion « à vie » est supprimé. Le cas SubscriptionType::LIFETIME disparaît de l'énumération, et avec lui la surcharge de l'écouteur qui remplaçait la période de saison par une période sans fin, le constructeur nommé MembershipPeriod::lifetime(), l'état MemberFactory::lifetime() (aucun appelant) et la traduction « À vie ». Motif : une end_date NULL contredit frontalement l'invariant du moteur — une période vit dans sa saison — et une seule ligne suffisait à faire refuser memberships:sync sur le tenant entier, sans issue automatique. La colonne members.subscription_type est conservée, l'énumération aussi, à un seul cas — non pas comme point d'extension du régime rolling_year, qui appartient à MembershipRenewalType (l'axe du tenant, cf. entrée suivante), mais parce que la colonne reste en base par décision PR3 : elle est castée sur le modèle, portée par l'entité de domaine, le mapper, les DTO et le front. SubscriptionType est l'axe de l'adhérent — quelle formule il a souscrite — et ne calcule aucune borne, sous aucun régime. UpdateMemberRequest valide désormais avec Rule::enum(SubscriptionType::class) au lieu d'une liste Rule::in(...) recopiée à la main, qui aurait continué d'accepter 'lifetime' en silence. Côté front, SubscriptionType (TypeScript) et les types Member / Person sont réduits au même cas unique. MembershipFactory::lifetime() est renommée unbounded() — elle ne modélise plus un régime produit mais un état de schéma, et sert à exercer les gardes qui le refusent.
  • Le régime « année civile » disparaît du code. MembershipPeriodCalculator (calcul fixed_period / rolling_year), le VO MembershipSettings (configuration d'adhésion du tenant) et la commande memberships:backfill sont supprimés avec leurs tests. Plus aucun appelant ne subsistait : la période est ancrée sur la saison depuis l'écouteur réécrit ci-dessus, et memberships:sync remplace le rattrapage. L'enum MembershipRenewalType est conservée comme point d'extension documenté (season seul mode implémenté, rolling_year réservé) — c'est la configuration d'un régime disparu qui part, pas la possibilité d'en ajouter un.
  • Les réglages « type de renouvellement / jour / mois » quittent la page Association. Le bloc disparaît de UpdateAssociationRequest (règles, withValidator() de validation jour/mois, messages), de AssociationController (props Inertia et écriture de settings), de settings/Association/Edit.vue et des clés de traduction (lang/fr/settings.php, resources/js/types/translations.d.ts). Ce n'était pas un simple champ inerte : la garde isset($validated['membership_renewal_type']) était toujours vraie puisque le formulaire postait la valeur, si bien que chaque sauvegarde de la page réécrivait fixed_period + 1/1 dans settings — un réglage menteur que plus rien ne lisait. AssociationController::update() ne touche désormais plus du tout à la colonne settings. AssociationMembershipSettingsTest est réécrit en test de non-régression : les anciens champs postés sont acceptés sans erreur mais non persistés, la page d'édition ne les expose plus, et les autres clés de settings sont préservées.
  • Member::scopeExpired() et Member::scopeExpiringSoon() sont supprimés, ainsi que leurs annotations @method. Ils interrogeaient members.membership_expires_at, colonne vouée à disparaître et déjà sans écrivain depuis 0.22.3.0 : les laisser offrait une réponse plausible mais fausse à « qui est expiré ». Le statut d'adhésion se dérive des lignes memberships. Aucun appelant n'existait.

Notes techniques

  • La propriété validatedAt est nullable avec valeur par défaut, et ce n'est pas cosmétique. SendRegistrationValidatedEmailListener implements ShouldQueue : l'événement est sérialisé en file, et SerializesModels::__unserialize() saute les clés absentes. Un job enfilé avant ce déploiement porte une charge sans la clé ; une propriété typée sans défaut y resterait non initialisée — un état distinct de null dont tout accès lève une Error, qu'un simple ?? ne rattrape pas. Le défaut null rend cet état structurellement impossible et l'accesseur validatedAt() replie sur l'horloge courante. Un test désérialise explicitement une charge amputée de la clé (tests/Feature/Events/RegistrationValidatedTest.php) ; sonde de falsifiabilité passée : retirer le repli fait tomber ce test.
  • L'invariant « RegistrationValidated est dispatché à DB::transactionLevel() === 0 » reste la condition de sûreté de l'écouteur. Son catch (\Throwable) avale l'échec sans rethrow — acceptable seulement parce qu'aucune transaction appelante n'est en cours, sans quoi une écriture d'adhésion ratée laisserait une transaction marquée. MembershipDispatchTransactionLevelTest couvre désormais quatre chemins au lieu de deux (CancelRegistrationService::cancel et HelloAssoPaymentConfirmationService::confirm s'y ajoutent), chacun vérifiant que l'inscription atteint bien Validated et que le niveau de transaction observé au dispatch est le niveau de base.
  • La réconciliation est ancrée sur la saison, jamais sur les bornes. Une ligne existante est annulée si et seulement si son season_id est NULL (ligne héritée) — jamais parce que sa saison est absente des cibles : la seule façon pour une saison d'en sortir est que l'inscription validée qui la portait ait été annulée depuis (la saison, elle, ne peut pas avoir disparu, season_id étant en cascade), et la règle produit est que l'adhésion reste acquise. Une cible est écrite si et seulement si aucune ligne non annulée ne porte déjà cette saison — « premier écrivain gagne, par saison », donc une période déjà posée à la main n'est pas réécrite. Comparer des bornes aurait laissé passer la forme observée sur club-asso : une ligne héritée disjointe de sa cible ne viole aucune contrainte sur aucun moteur, si bien qu'une annulation oubliée laisserait deux lignes actives sans qu'aucune exception ne soit levée. Le test correspondant assère donc toHaveCount(1) sur l'état des lignes, pas l'absence d'exception. La vérification finale, sous verrou, exige que chaque saison cible soit couverte et qu'aucune ligne héritée ne subsiste — une inclusion, pas une égalité d'ensembles : exiger l'égalité transformerait la conservation d'une adhésion acquise en échec par adhérent, et la ligne ne survivrait alors que par rollback. Les lignes conservées hors cible sont comptées et affichées.
  • Une transaction par adhérent, verrou d'abord. SyncMemberMembershipsUseCase::execute() prend lockMember() avant toute lecture, puis annule et écrit dans la même transaction. L'écriture passe par RecordMembershipPeriodUseCase, seul écrivain : sa transaction imbriquée devient un SAVEPOINT, ce dont dépend la traduction d'un 23P01 sans empoisonner la transaction englobante. Un catch (Throwable) par adhérent, placé hors de la transaction, compte l'échec, le journalise et poursuit la campagne ; l'adhérent en échec est défait en entier — ni annulation, ni écriture partielle — et la commande sort en code 1.
  • Le regroupement des inscriptions par saison est fait en PHP, jamais en SQL. L'ancre d'une saison est le min(registered_at) des inscriptions validées de cette saison, départagé par id. Un MIN(uuid) en SQL ne fonctionne que sur PostgreSQL : la CI, sur SQLite, ne l'aurait jamais exercé. Un test vérifie que le résultat ne dépend pas de l'ordre d'insertion.
  • SeasonIndex filtre tenant_id explicitement. En console, TenantContext est nul et le scope global de BelongsToTenant est donc inerte : sans filtre explicite, la commande lirait les saisons de tous les tenants. Aucun test n'initialise de contexte — c'est précisément l'absence de contexte qui rend le scope inerte, et tester sous contexte masquerait un filtre manquant. Les saisons sont chargées une seule fois par exécution (pas de N+1 sur plusieurs milliers d'adhérents).
  • Le moteur ne filtre jamais sur seasons.status, et un test le fixe. Une saison draft est une saison à venir, pas une saison invalide : c'est l'état normal d'une campagne de rentrée ouverte avant le 1er septembre — la campagne réelle de lezards-animes a recueilli ses inscriptions validées entre mai et août 2026 sur une saison 2026-09-01 → 2027-08-31 restée draft. Un filtre sur le statut priverait donc d'adhésion toute une campagne. SeasonFactory produisant Draft par défaut, l'invariant était couvert par accident dans toute la suite ; il est désormais nommé et fixé explicitement (CreateMembershipFromRegistrationListenerTest), ce qui le fait survivre à un changement du défaut de la factory. Sonde de falsifiabilité passée : ignorer les saisons draft dans l'écouteur fait tomber ce test.
  • OverlappingMembershipException est journalisée en warning, pas en error, et attrapée avant \Throwable. Un chevauchement est un refus attendu du moteur (23P01 traduit en PR1), pas une panne : il n'écrit rien et ne remonte pas. Toute autre exception passe en error avec registration_id. Une inscription sans membre ou sans saison résolvable sort en error sans écrire. Ce warning étant la seule trace de l'anomalie, son contexte porte member_id et season_id en plus de registration_id : sans eux l'opérateur ne peut ni identifier l'adhérent ni savoir quelle saison est restée sans période, et doit requêter l'inscription à la main pour chaque ligne du journal. Les deux valeurs sont lues sur l'inscription — colonnes NOT NULL — et non sur les variables assignées à l'intérieur du try, qui ne sont pas garanties affectées quand l'exception survient.
  • Retirer un cas d'énumération est une opération de DONNÉES, pas seulement de code. Le cast enum de Member transforme toute valeur 'lifetime' restée en base en ValueError fatale, sur tout chemin de lecture d'un adhérent — fiche, liste, mapper. La migration normalize_lifetime_subscription_type réécrit donc ces lignes en 'annual' avant que le cast ne les rencontre. Sur la copie de production du 2026-08-18 elle est un no-op mesuré (880 adhérents, tous annual, et zéro memberships.end_date NULL) : elle couvre la fenêtre entre cette mesure et le déploiement, pendant laquelle UpdateMemberRequest acceptait encore 'lifetime'. Elle passe par DB::table() et non Eloquent — Member porte le scope global BelongsToTenant, qui hors contexte tenant ne toucherait aucune ligne, alors qu'une migration doit balayer tous les tenants. Son down() est volontairement vide : le régime d'origine des lignes réécrites n'est pas conservé, et restaurer un cas que l'énumération ne connaît plus casserait la lecture — ici c'est le rollback qui serait destructeur, pas la montée.
  • L'invariant « toute période écrite par l'application est bornée » remplace l'ancien test du membre à vie, sans devenir une tautologie. Épingler l'absence d'un cas d'énumération n'aurait rien prouvé et serait tombé à l'arrivée du régime rolling_year ; ce qui est fixé est la propriété qui doit lui survivre — la borne haute vaut la fin de saison, quelle que soit l'ancre. Le test de bout en bout écouteur → commande est retourné dans le même mouvement : il vérifie désormais que l'écouteur ne peut plus armer la pré-vérification. Sonde de falsifiabilité passée : réintroduire une période sans fin dans l'écouteur fait tomber 13 tests, dont ces deux gardes.

0.22.4.0 — 2026-08-18

Modifications

  • Les saisons du jeu de démonstration sont ancrées sur l'horloge, plus sur des millésimes figés. SeasonFactory produisait une saison tirée au hasard entre 1900 et 2090 : environ deux tirages sur trois tombaient sur une saison déjà close, et les cinq seeders de démonstration codaient en dur 2024-2025 / 2025-2026. Une inscription datée hors des bornes de sa saison fabrique, une fois le moteur d'adhésion branché, une période début = max(saison.début, validation) postérieure à fin = saison.fin — donc inversée. La fabrique expose désormais window() / windowFor() et les états explicites past() / current() / future(), et les seeders dérivent chaque borne, nom de campagne et date d'expiration de seasonWindow(-1) / seasonWindow(0). Sans ce préalable, la non-déterminisme se serait manifesté en CI aléatoire une fois l'écouteur d'adhésion actif partout.
  • createRegistration() exige une date d'inscription et un statut. Les deux paramètres passent en position 5 et 6 du helper partagé HasMemberSeedHelpers, familyPosition recule en 7 : tout appel resté à l'ancienne signature échoue bruyamment au lieu de glisser silencieusement une position de famille dans la date. assertRegisteredWithinSeason() refuse en outre toute inscription hors bornes — une borne codée en dur qui a vieilli casse le seed au lieu d'écrire une période inversée.
  • Les cinq seeders de démonstration suppriment puis recréent leur tenant. DemoLigue56Seeder, EpgvSeeder et WaterPoloSeeder conservaient à la place une branche « tenant existant → données préservées » qui ne rejouait qu'une fraction du jeu de données ; ils sont désormais rejouables comme AssoCultureSeeder et ClubAssoSeeder, et DemoResetSeeder les appelle tous les cinq.
  • Les settings d'un tenant sont écrits comme objet JSON. TenantFactory et les cinq seeders posaient [], qui sérialise en tableau JSON et casse toute requête settings->>'clé' ou jsonb_typeof. La valeur de démonstration est {"hello_asso_sandbox": true} : un jeu fabriqué ne doit jamais viser HelloAsso en production.
  • L'horodatage technique created_at d'une inscription suit sa date d'inscription. HasMemberSeedHelpers::createRegistration() alignait registered_at sur la saison mais laissait created_at à now() — or c'est created_at que lit memberships:backfill pour ancrer la période (BackfillMembershipsCommand, périodes « ancrées au passé »). Une inscription de saison révolue rejouait donc l'inversion max(début de saison, aujourd'hui) > fin de saison sans qu'aucune assertion portant sur registered_at ne bronche : sonde de falsifiabilité, 7 tests sur 8 tombent si l'alignement est retiré.

Ajouts

  • Garde de production sur les seeders de démonstration. demoSeedIsAllowed() refuse le cycle supprime/recrée quand app()->isProduction(). Les tenants demo-ligue-56, demo-epgv-44 et demo-cholet-waterpolo existent en production et portent des données de démonstration commerciale : rejouer leur seeder les détruirait. clevercloud/post_build.sh n'appelle que PermissionSeeder et RoleSeeder, mais un db:seed --class=… lancé à la main aurait suffi.
  • Quatre fichiers de tests de non-régression (tests/Feature/Database/) : déterminisme de SeasonFactory (la saison par défaut contient toujours le jour courant, sur 50 itérations et par décalage explicite), idempotence des cinq seeders (chacun rejoué deux fois, un seul tenant, settings objet JSON), et absence d'inscription hors bornes de saison — dont trois cas sous horloge déplacée (2026-09-15, 2027-03-01, 2028-08-30), sans quoi l'assertion serait tautologique tant que l'horloge réelle coïncide avec les millésimes autrefois codés en dur. S'y ajoutent le déterminisme de MembershipFactory (période par défaut figée, réutilisation d'une saison au libellé libre, shifted(), et deux refus d'adhésion en double dont un qui isole memberships_unique_season en faisant varier start_date à saison constante — sans quoi memberships_no_overlap (tenant_id, member_id, start_date) suffirait à faire passer le test) et la survie du cycle supprime/recrée en présence d'une adhésion.

Corrections

  • Le test de chevauchement partiel du listener ne dépend plus de l'horloge. CreateMembershipFromRegistrationListenerTest posait sa saison antérieure avec Season::factory()->past(). past() dérive son nom de start_date via startYearOf(), qui bascule de millésime le 1er septembre : à partir du 2026-09-01, past() produit « 2025-2026 » — le nom exact de la saison que validatedRegistration() crée sur le même tenant avec les bornes 2025-09-15 → 2026-06-30. La CI serait donc passée du vert au rouge sans qu'aucun commit ne soit poussé, sur une violation unique(tenant_id, name) dont la cause serait la date d'exécution. Les bornes de la saison antérieure sont désormais absolues (2024-09-15 → 2025-06-30) : le nom « 2024-2025 » est constant. La saison n'existe dans ce décor que pour porter un season_id — ses bornes ne sont lues par aucune assertion.
  • MembershipFactory ne tire plus sa période au hasard. Depuis que season_id est déduit de start_date, la période par défaut est devenue porteuse : un tirage sur 366 jours ne franchit qu'une seule fois la frontière du 1er septembre, si bien que deux memberships par défaut pour un même membre tombaient sur la même saison la plupart des jours de l'année — violant l'index unique partiel memberships_unique_season à un taux qui variait avec la date d'exécution, et virant au vert deux jours par an. La période par défaut est désormais la fenêtre de la saison courante : la collision est systématique et lisible, et un test qui a besoin d'une seconde saison écrit ->shifted(-1) (miroir de SeasonFactory::shifted()) ou ->period(...). La résolution de saison cherche en outre la saison qui contient la période et non celle qui porte le nom calculé — chercher par nom manquait une saison aux bonnes bornes mais au libellé libre et en fabriquait une seconde, identique.
  • Quatre décors de test ne créent plus deux saisons chevauchantes pour un même tenant. Rendre SeasonFactory déterministe donne à toutes les saisons par défaut d'un tenant des bornes identiques : RegistrationControllerTest, RegistrationExportTest (deux cas) et SeasonControllerTest produisaient un chevauchement que seasons_no_overlap (EXCLUDE USING gist) refuse en production, et que SQLite laisse passer sans un mot. Les saisons secondaires reçoivent des fenêtres explicitement disjointes (past() / current() / SeasonFactory::window(-1)). Ce n'est pas cosmétique : la pré-vérification de memberships:sync traitera des saisons chevauchantes comme un échec, et un décor chevauchant produit deux season_id distincts que l'index unique accepte alors que la contrainte GiST rejette (23P01 en production).
  • EpgvSeeder ne perd plus ses activités. addCoursesDemoFeatures() (cours Gym Douce et Pilates, campagne « Inscriptions Activités Vitafédé », 12 inscrits) n'était atteignable que par la branche « tenant existant » : le passage au cycle supprime/recrée l'aurait rendue morte, et le tenant serait reparti sans aucun cours, en silence. L'appel est câblé dans le chemin de création.

Notes techniques

  • memberships.season_id est en noActionOnDelete(), et le re-seed le supporte réellement. Le raisonnement d'origine — « NO ACTION est différé en fin d'instruction, donc les cascades sœurs tenants -> seasons et tenants -> memberships se résolvent » — est une propriété de PostgreSQL : SQLite vérifie NO ACTION immédiatement, et la divergence irait ici dans le sens inverse de l'habituel (rouge en CI, vert en production). L'idempotence prouvée jusqu'ici ne portait sur rien, memberships restant vide pendant le test faute de membership_renewal_type dans les settings de démonstration : l'écouteur d'adhésion y est inerte. Le test pose désormais explicitement une adhésion ancrée sur une saison du tenant avant de rejouer le seeder — la branche delete-recreate est exercée, et elle passe sur les deux moteurs.
  • Code mort retiré. addNewFeatures() et refreshDemoMeetings() disparaissent des trois seeders convertis : elles n'existaient que pour la branche « tenant existant ». createDemoMeetings() et addCoursesDemoFeatures() sont conservées et appelées depuis run().

0.22.3.0 — 2026-08-18

Retraits

  • Le renouvellement d'adhésion à la main n'existe plus. RenewMembershipUseCase, son DTO, sa FormRequest, la route POST members/{member}/renew, la méthode MemberController::renewMembership() et Member::renewMembership() sont supprimés. Ce chemin écrivait current_membership_year / membership_expires_at directement sur members, en concurrence avec RecordMembershipPeriodUseCase, désormais seul écrivain des périodes d'adhésion : deux sources de vérité pour la même question produisaient des réponses différentes selon la page consultée. Aucune interface ne l'exposait (le bouton « Renouveler » n'existait pas côté Vue), la suppression est donc invisible pour l'utilisateur.
  • UpdateMemberUseCase n'accepte plus les champs d'adhésion. current_membership_year et membership_expires_at disparaissent de UpdateMemberCommand et des règles de UpdateMemberRequest : soumis dans la charge utile d'une mise à jour de membre, ils sont maintenant ignorés au lieu d'écraser la période calculée. Les colonnes elles-mêmes restent en base — MemberMapper continue de les écrire — jusqu'à leur suppression en PR3.

Ajouts

  • Deux saisons ne peuvent plus se chevaucher. Une contrainte d'exclusion GiST seasons_no_overlap (tenant_id WITH =, daterange(start_date, end_date, '[]') WITH &&) interdit le recouvrement au niveau PostgreSQL. Les bornes sont inclusives des deux côtés : deux saisons qui se touchent (début = fin + 1 jour) sont acceptées, deux saisons qui partagent ne serait-ce qu'une journée sont refusées. SQLite ne connaît pas EXCLUDE : la migration y est un no-op assumé, et la règle est donc doublée d'une validation applicative — c'est cette dernière que la CI exerce réellement, la contrainte de base n'étant qu'un dernier rempart. Sans cette garantie, une date de validation pouvait appartenir à deux saisons à la fois, ce qui rendait l'ancrage d'une adhésion à une saison arbitraire.
  • Les bornes d'une saison se figent dès qu'elle porte des adhésions. StoreSeasonRequest et UpdateSeasonRequest refusent un chevauchement avec une autre saison du même tenant, et UpdateSeasonRequest refuse en plus de déplacer start_date ou end_date dès qu'au moins une adhésion non annulée est rattachée à la saison — le message indique combien. Renommer reste possible, et resoumettre les mêmes bornes n'est pas considéré comme une modification. Déplacer les bornes d'une saison déjà peuplée aurait redaté silencieusement des périodes d'adhésion déjà écrites.

Notes techniques

  • La garde applicative compare des dates, pas des chaînes. GuardsSeasonBoundaries filtre les saisons candidates avec whereDate() (strftime('%Y-%m-%d', col) sous SQLite, col::date sous PostgreSQL). Un where() brut est asymétrique dès que la colonne date porte une partie horaire — ce que fait SQLite, typé dynamiquement, en stockant '2025-09-01 00:00:00' face à une borne soumise sur 10 caractères : '2025-09-01 00:00:00' <= '2025-09-01' est faux, et le chevauchement « par la gauche » (fin de la nouvelle saison = début d'une existante) passait. PostgreSQL comparait juste, la faille était donc invisible en production mais réelle dans la seule surface que la CI exerce. Un test symétrique épingle désormais les deux sens. Toute pré-vérification de chevauchement à venir doit reprendre whereDate(), sans quoi elle sera aveugle de la même façon.

0.22.2.0 — 2026-08-18

Ajouts

  • La saison entre dans la ligne d'adhésion. La table memberships porte désormais une colonne season_id (nullable, ON DELETE NO ACTION : supprimer une saison qui porte encore des adhésions est refusé, jamais silencieusement propagé) et un index unique partiel memberships_unique_season sur (tenant_id, member_id, season_id) restreint aux adhésions non annulées : un membre ne peut plus avoir deux adhésions actives sur la même saison. Contrairement à la contrainte d'exclusion GiST, cet invariant est exprimable sur SQLite, donc réellement exercé par la CI. Les lignes héritées, sans saison, ne se gênent pas entre elles (un index unique n'oppose jamais deux NULL) et restent donc intactes.
  • Règle d'adhésion ancrée sur la saison, exprimable dans le domaine. MembershipPeriod::forSeason() calcule la période cible : la fin est toujours la fin de saison, le début est le plus tardif entre le début de saison et la date de validation. Les trois dates sont normalisées au jour avant toute comparaison, pour coller aux colonnes date de la base — une validation le dernier jour de la saison à 23h59 produit une adhésion d'un jour, pas une saison entière. Une ancre hors saison retombe sur les bornes pleines de la saison plutôt que de produire une période inversée. S'y ajoutent MembershipPeriod::equals() et l'énumération MembershipRenewalType.

Modifications

  • Le port d'adhésion transporte la saison et l'auteur. RecordMembershipPeriodRequest exige désormais un seasonId explicite (nullable, sans valeur par défaut : chaque appelant tranche), propagé jusqu'à la ligne écrite avec created_by. Quand la demande porte une saison, l'idempotence de l'écrivain unique se lit avec la clé de la base — la saison — et non plus avec les bornes : une ligne qui englobe la période sans porter cette saison n'est plus considérée comme couvrante, sans quoi aucune ligne ne porterait jamais la saison visée et l'adhérent serait invisible saison par saison. Les deux appelants existants — le listener de validation d'inscription et memberships:backfill — passent null : ils écrivent des lignes héritées, que la migration de rectification à venir doit pouvoir reconnaître comme telles.
  • MembershipRepositoryInterface::cancel() cible une adhésion précise. La méthode prend le membre en plus de l'adhésion, exige une transaction ouverte, ignore les adhésions déjà annulées et renvoie le nombre de lignes affectées, ce qui rend l'annulation vérifiable par l'appelant au lieu d'être supposée.

Obsolète

  • MembershipPeriodCalculator et MembershipSettings : la règle d'adhésion se déduit désormais de la saison, plus d'un réglage de tenant. Supprimés à la fin du chantier.

Notes techniques

  • Aucun comportement utilisateur ne change : cette version rend la règle cible exprimable et l'invariant applicable, sans qu'aucun appelant ne l'utilise encore.
  • Sur SQLite, ajouter une clé étrangère reconstruit la table et perd silencieusement le prédicat des index partiels. La migration recrée donc explicitement memberships_no_overlap avec son WHERE cancelled_at IS NULL, sans quoi l'index serait devenu total et aurait interdit toute réadhésion après annulation. Un test de migration verrouille ce point.

0.22.1.2 — 2026-08-18

Corrections

  • Les dates d'un adhérent reculaient à chaque enregistrement. EloquentMemberRepository::store() reportait les attributs sur le membre existant via toArray(), qui sérialise en ISO-8601 UTC tous les casts temporels : sous APP_TIMEZONE=Europe/Paris, minuit local devient 22h ou 23h la veille, et la valeur relue au tour suivant reculait encore. La dérive était donc CUMULATIVE. Elle coûtait un jour entier par enregistrement aux colonnes date — date_premiere_adhesion, la date d'entrée dans l'association, et membership_expires_at — et une heure par enregistrement à la colonne datetime date_acceptation_documents, qui finissait elle aussi par changer de jour. Le report passe désormais par getAttributes(), qui rend les valeurs brutes déjà au format de stockage. Un test enregistre deux fois de suite le même membre et épingle les trois valeurs exactes.

Notes techniques

  • Les valeurs déjà décalées en production ne sont pas rattrapées par ce correctif : un membre enregistré N fois depuis la mise en service porte une date_premiere_adhesion reculée de N jours. Le périmètre exact et l'opportunité d'une reprise restent à arbitrer.
  • Le dépôt des personnes (EloquentPersonRepository) est indemne : son mapper affecte les attributs un par un sur le modèle existant, sans passer par toArray(). persons.date_naissance n'a donc jamais dérivé. EloquentMemberRepository était le seul appelant de fill() du dossier app/.

0.22.1.1 — 2026-08-01

Modifications

  • Un seul chemin d'écriture pour les périodes d'adhésion. RecordMembershipPeriodUseCase devient l'écrivain unique : le listener de validation d'inscription et la commande memberships:backfill y passent tous les deux, au lieu de porter chacun sa propre logique d'insertion. Le port MembershipRepositoryInterface et MembershipOverlapGuard, livrés sans consommateur en 0.22.0, sont enfin branchés. Aucun changement visible côté utilisateur.
  • L'invariant de non-chevauchement est appliqué avant la base, et vérifié en CI. L'ancienne déduplication testait UNE date (« une adhésion couvre-t-elle le jour de début ? »), pas un intervalle : une période englobant une adhésion existante sans partager son jour de début passait le contrôle et n'était rattrapée que par la contrainte GiST en production, sans trace. Le contrôle est maintenant un vrai test d'intervalle (MembershipPeriod::contains()) exécuté sous verrou membre. La couverture complète reste un cas nominal silencieux (panier à deux cours, backfill rejoué) ; le chevauchement partiel est refusé et tracé avec les deux périodes en clair.
  • La violation de contrainte de la base est traduite en exception métier. EloquentMembershipRepository::add() convertit la violation de memberships_no_overlap en OverlappingMembershipException — contrainte d'exclusion GiST sur PostgreSQL (SQLSTATE 23P01), index unique partiel de secours sur SQLite. Aucun appelant n'a plus besoin de connaître un SQLSTATE. Les autres erreurs SQL remontent intactes, volontairement.

Corrections

  • Le dry-run du backfill ne ment plus sur ce que fera le vrai run. Il partageait la déduplication par date de début : une période partiellement chevauchante était annoncée « déjà couverte » alors que le vrai run aurait tenté de l'écrire. Il rejoue désormais les deux passes du use case dans le même ordre — couverture complète (contains) puis chevauchement (overlaps) — en lecture seule. Les compteurs affichés changent en conséquence, et deux tests épinglent l'égalité dry-run / vrai run pour que le miroir ne rediverge pas.
  • Le backfill trace les chevauchements partiels au lieu de les avaler. Une période candidate refusée parce qu'elle chevauche une adhésion existante était comptée dans les « ignorées (déjà couvertes) », sans log — alors que c'est une anomalie à instruire, pas de l'idempotence. Elle est maintenant distinguée dans la sortie (« dont N en chevauchement partiel ») et journalisée avec le membre et la période, comme le fait déjà le listener de validation.
  • La traduction du refus PostgreSQL vise la bonne contrainte. Le SQLSTATE 23P01 était traduit en « chevauchement » quelle que soit la contrainte d'exclusion à l'origine. memberships n'en porte qu'une aujourd'hui, mais une seconde aurait vu ses violations rangées parmi les chevauchements — donc avalées en silence par le backfill et le listener. Le nom memberships_no_overlap, que PostgreSQL cite verbatim, est désormais vérifié en plus du code.
  • Réglages d'adhésion : la garde de pré-configuration accepte moins large. Elle ne testait que la présence d'une valeur ; une valeur non nulle mais inconnue (chaîne vide, fixed-period mal orthographié) la franchissait, puis retombait silencieusement en période glissante — le régime d'adhésion de tout un tenant basculait sans signal. MembershipSettings::isConfigured() n'accepte plus que fixed_period et rolling_year.

0.22.1.0 — 2026-07-31

Ajouts

  • Codes de réduction par campagne. Un adhérent saisit un code dans le formulaire public (ou un agent en back-office) et obtient une remise en pourcentage (1-100 %) sur son panier. Chaque code est configuré par campagne : ciblage optionnel par rubrique, quota d'utilisations, date d'expiration (inclusive). Techniquement, un code est une PricingRule dont la colonne code est non-null — même table, même moteur de calcul que les remises automatiques ; Campaign::pricingRules() filtre désormais whereNull('code') pour que les dix appelants existants ne voient jamais un code. CRUD back-office sur la carte campagne (créer, modifier, archiver, supprimer), saisie côté funnel avec badge de confirmation et remise reflétée dans le total affiché. Doc : Docs/Features/Promo-Codes.md.
  • Consommation par panier, pas par compteur. La table promo_code_redemptions enregistre une ligne par panier ; deux index uniques partiels portent les règles métier (un seul code par panier, une seule consommation active par panier). Le quota est gardé deux fois — pré-filtre dans CartPricingRules::resolveUsableCode() et garde post-verrou dans RedeemPromoCodeService — et la ligne est libérée à l'abandon du panier. La remise survit aux mutations de cours : MutateRegistrationCoursesService et CartMutationService recalculent avec CartPricingRules::forCart(), pas avec la relation filtrée.
  • Validation immédiate d'un panier ramené à 0 €. Un panier intégralement couvert par un code ne passe plus par HelloAsso : il est validé sur-le-champ, le moyen de paiement est réinitialisé et le mail de confirmation part sans ligne de reste à payer. Le code de réduction appliqué est tracé dans le snapshot de facture et affiché sur le détail panier.

Sécurité

  • Oracle d'existence de codes fermé. L'autorisation vivait dans le contrôleur, alors que Laravel résout rules() et withValidator() avant lui : n'importe quel utilisateur authentifié du tenant pouvait distinguer « code inexistant » de « code existant mais interdit » via les messages de validation. L'autorisation est remontée dans StorePromoCodeRequest::authorize(). Côté public, la réponse d'application d'un code est uniformisée et un seau anti-énumération partagé limite les tentatives.
  • Fuite de quota fermée. Le décompte pouvait être contourné en concurrence ; la consommation se fait maintenant sous verrou, dans une transaction gardée, et le prédicat est testé des deux côtés (code utilisable / code épuisé).

Modifications

  • La suite de tests ne peut plus atteindre PostgreSQL. La connexion pgsql est dé-configurée pour les tests et tests/Unit/TestDatabaseIsolationTest.php monte la garde — il vérifie aussi qu'aucune trace d'un flag d'opt-in ne revient dans le dépôt. Motif : un tel flag avait vidé la base de développement. Les agrégats sensibles à la divergence SQLite↔PostgreSQL se vérifient désormais à la main sur la base de dev, jamais via la suite.

0.22.0.4 — 2026-07-31

Corrections

  • Les helpers de test Filament étaient des no-op silencieux en Docker local. Le conteneur exporte APP_ENV=local ; dans phpunit.xml, <env name="APP_ENV" value="testing"/> ne gagnait ni sur la variable d'environnement (pas de force="true") ni sur $_SERVER, que l'adapter Dotenv de Laravel consulte avant $_ENV/putenv(). Résultat : app('env') === "local", donc Application::runningUnitTests() renvoyait false et fillFormDataForTesting() sortait immédiatement — fillForm() / setTableActionData() ne remplissaient plus rien, sans lever d'erreur. Le fix pose les deux lignes (<env force="true"> + <server>). Aucun impact en CI, où APP_ENV=testing est posé au niveau du processus : la divergence était strictement locale.

Ajouts

  • Garde-fou tests/Feature/TestEnvironmentTest.php : vérifie que APP_ENV vaut testing dans app()->environment(), $_SERVER, $_ENV et getenv(). Sans lui, une régression sur phpunit.xml se manifeste par des erreurs de validation incompréhensibles dans des tests Filament sans rapport.

0.22.0.3 — 2026-07-29

Ajouts

  • Couche port/domaine du moteur d'adhésions (aucun changement visible pour les utilisateurs) : port MembershipRepositoryInterface + adaptateur Eloquent, garde de domaine MembershipOverlapGuard (7 formes de recouvrement, cas jointif accepté), entité MembershipRecord. La garde applique en PHP l'invariant que seule la contrainte PostgreSQL memberships_no_overlap tenait jusqu'ici — donc jamais exercé en CI, où SQLite ne pose qu'un index unique partiel. La couche n'a aucun appelant : elle prépare la bascule en lecture (PR2) sans toucher au comportement actuel. Séquence d'écriture à respecter et dette assumée : Moteur de périodes d'adhésion.

Corrections

  • MembershipPeriod acceptait des périodes que PostgreSQL refuse. Deux divergences prouvées contre un vrai PostgreSQL puis fermées : (1) les bornes sont normalisées à startOfDay() avant validation, car start_date/end_date sont des colonnes date — une composante horaire faisait diverger le verdict de chevauchement du VO et celui du daterange ; (2) une période inversée (start > end) est désormais rejetée par le VO, PostgreSQL la refusant avec le SQLSTATE 22000 et non 23P01.

Documentation

  • Nouveau document d'architecture : Global scope tenant vs filtre tenant_id explicite — à lire avant d'écrire un repository, un job ou une commande qui passe un tenant_id en argument. Le global scope BelongsToTenant s'ajoute au filtre explicite au lieu de le remplacer, et ne s'applique pas aux INSERT : contexte divergent = lectures vides + écritures qui partent quand même, sans le moindre signal.