Aller au contenu
Candidater

Journal des modifications

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

51 versions correspondent à « adhérent ».

Versions 11 à 20 sur 51

0.25.0.1 — 2026-09-04

Ajouts

  • Chaque méthode de paiement affiche désormais des instructions de règlement, identiques sur les trois surfaces qui les rendent (#286, 2026-09-04). PaymentInstructionsResolver remplace BankTransferInstructionsResolver — supprimé sans résidu — et produit un PaymentInstructionsSet de 0 à 2 blocs (acompte / reste à régler) à chaînes déjà rendues ; l'e-mail de confirmation de panier, l'e-mail de relance et l'écran de succès public se contentent d'afficher : trois partials Blade (HTML, texte, markdown) et le composant Vue PaymentInstructions.vue. Auparavant, seul le virement produisait des instructions ; chèque, espèces, carte hors ligne, HelloAsso et moyen personnalisé n'en affichaient aucune.
  • La description d'un moyen de paiement personnalisé survit désormais au submit du formulaire d'inscription publique (#286, 2026-09-04). Elle est portée par le bloc d'instructions rendu sur les trois surfaces au lieu de disparaître une fois l'inscription passée.

Corrections

  • La carte bancaire hors ligne annonçait un règlement en ligne (#286, 2026-09-04). PaymentInstructionsResolver::actionText() dégroupe désormais PaymentMethod::CreditCard (« Règlement par carte bancaire sur place, auprès de l'association. ») de PaymentMethod::HelloAsso (paiement en ligne), auparavant confondues sous la même phrase.
  • L'ordre du chèque se replie sur le nom du tenant (#286, 2026-09-04). tenant_payment_methods ne porte pas encore de champ dédié au destinataire ; PaymentInstructionsResolver::checkPayee() rend tenant->name. Le paramètre $row, inutilisé pour l'instant, est conservé pour y brancher ce champ sans changer les appelants.
  • Les deux canaux de paiement d'une règle d'acompte sont désormais distingués (#286, 2026-09-04). CampaignDepositRule::settlement_method est le canal de l'acompte (« Moyen de règlement de l'acompte » côté admin), tandis que la méthode choisie par l'adhérent — qui déclenche la règle via applicable_payment_methods — reste celle du reste à régler. Quand les deux blocs sont rendus, chacun porte son étiquette de portée (« Pour l'acompte », puis « Pour le solde ») : les deux e-mails alignent les blocs sans séparateur visuel, et un second bloc nu s'y lit comme une seconde consigne d'acompte. L'étiquetage du bloc du reste est décidé après filtrage : resté seul, ce bloc couvre tout le montant réclamé et non un reliquat, donc il ne porte aucune étiquette — comme la ligne « Moyen de paiement : … » qu'affichait l'e-mail auparavant. Le docblock de CampaignDepositRule, qui décrivait settlement_method comme la méthode du solde, est corrigé.
  • L'e-mail de relance annonçait un total de panier que le bloc « Pour l'acompte » contredisait en silence (#286, 2026-09-08). Sous règle d'acompte, la relance affichait le solde entier du panier puis, juste en dessous, un bloc d'instructions étiqueté « Pour l'acompte » ne portant aucun chiffre : le lecteur rattachait le total au canal de l'acompte et pouvait virer l'intégralité sur le mauvais canal. CartPaymentReminderMail calcule désormais le montant réellement exigible — par ligne, l'acompte restant (deposit_amount_due - total_paid) tant qu'il est strictement positif et inférieur au solde, sinon le solde — et le mail ajoute « À régler maintenant (acompte) : … » suivi du reliquat, uniquement quand cette somme est strictement inférieure au total. Garde d'homogénéité : la ligne n'apparaît que si toutes les inscriptions relancées sont en contexte d'acompte. Les blocs d'instructions sont résolus sur la seule ligne représentative ; sur un panier mixte, une somme étiquetée « acompte » ne correspondrait à aucun des canaux affichés, donc le total reste seul et le comportement est inchangé. Correctif du montant réclamé côté relance, hors périmètre initial des instructions de paiement de cette PR.
  • La ligne représentative d'un panier pouvait être une ligne gratuite dont la méthode de paiement avait été effacée, faisant partir la confirmation sans instructions (#286, 2026-09-04). whereIn sur des UUID ne garantit aucun ordre : le panier est désormais trié par member_index, et la ligne représentative choisie par le nouveau helper CartRepresentative::pick() — la première ligne au montant dû strictement positif, ou la première ligne à défaut d'un panier entièrement gratuit.
  • Le mail de confirmation d'un panier réglé (0 €, ou soldé) ne porte plus la ligne « Moyen de paiement : — » (#286, 2026-09-04). Un panier Validated et soldé (hasOutstandingBalance() à false) fait court-circuiter PaymentInstructionsResolver::forCart() en PaymentInstructionsSet::empty() : aucun bloc d'instructions n'est rendu, volontairement — pas de paiement dû, pas d'instructions. Un panier Validated mais encore partiellement payé continue de produire le bloc de son reste à régler. À l'inverse, un panier encore dû dont aucune méthode ne se résout reçoit un bloc générique (« Moyen de paiement : — » et phrase de règlement générique), comme avant : le jeu vide signifie donc « plus rien à régler », jamais « méthode inconnue ». FreeCartSettlementTest > I-1 est aligné sur ce comportement.
  • Un moyen de paiement dépublié pouvait voir son RIB ressortir dans les mails et sur l'écran de succès public (#286, 2026-09-08). Les deux requêtes TenantPaymentMethod de PaymentInstructionsResolver::resolveMethodKey() ne filtraient ni is_enabled ni is_public ; elles passent désormais par le scope public(), le même que le reste de la surface publique. Le canal le plus exposé est settlement_method : il est choisi par l'admin dans CampaignDepositRule::eligiblePaymentMethods(), qui rend tous les enums sauf HelloAsso sans consulter la configuration du tenant — il ne passait donc par aucun filtre de publication ailleurs. Ligne absente ⇒ pas d'échec : le libellé de la méthode reste rendu, seuls les détails (IBAN, BIC…) disparaissent au profit de la phrase générique. Aucun orderBy n'est ajouté : l'index partiel uq_tenant_std_payment garantit au plus une ligne par (tenant, méthode standard).
  • Une panne de résolution retirait toute la section paiement des mails au lieu du seul bloc de détails (#286, 2026-09-08). Le catch (Throwable) de PaymentInstructionsResolver::forCart() rendait PaymentInstructionsSet::empty(), qui signifie « plus rien à régler » pour les trois surfaces — la confirmation et la relance annonçaient donc l'absence de paiement dû sur un panier encore dû. Avant ce résolveur, PublicCartConfirmationMail calculait sa ligne « Moyen de paiement » de son côté et une panne ne coûtait que le bloc RIB. Le catch renvoie désormais le bloc générique tant qu'un solde subsiste ; le jeu vide n'est conservé que si le panier est soldé — ou si la lecture du solde échoue elle aussi, seul cas où il reste l'état sûr. PublicCartConfirmationMail charge en outre payments explicitement : $settledNotice appelle hasOutstandingBalance(), et ne dépendre que du chargement en effet de bord du résolveur rendait cette lecture sensible à l'ordre des appels.

0.25.0.0 — 2026-09-04

Ajouts

  • Le registre des salariés existe, et un salarié peut participer à une réunion sans qu'on lui fabrique une fiche d'adhérent (#277, 2026-09-02). Nouvelle table employees et pivot employee_employee_types — un salarié cumule plusieurs types —, enums EmployeeType et EmployeeStatus, EmployeePolicy, pages Inertia Employees/Index|Show|Form. users.employee_id (nullable) relie un compte à sa fiche RH ; meeting_participants.person_id ajoute un quatrième mode de participation, mutuellement exclusif des trois autres. Un rôle coach et une permission view_own_courses ouvrent un calendrier en lecture seule, vers lequel la connexion redirige directement : il n'expose que les métadonnées des cours, aucune donnée personnelle d'adhérent.
  • Les comptes user_type = 'employee' sans fiche RH sont listés sur le registre, en mode dégradé (#280, 2026-09-02). EmployeeController::index() expose unlinkedStaff — les comptes du tenant dont employee_id est nul —, rendus avec un badge « Sans fiche RH » et un bouton « Créer la fiche ». Aucune donnée n'est écrite et aucun backfill n'est lancé : c'est un révélateur d'un écart existant, pas une migration. Le formulaire ne pré-remplit person_id que si un seul persons.email correspond (comparaison en minuscules, en une requête, sans N+1). La section disparaît dès qu'un filtre de statut est posé, et au-delà de la première page, où elle n'aurait plus de sens.
  • Kit de formation EPGV 44 et profils de règlement du jeu de démonstration (#273, 2026-08-27). Docs/Formations/EPGV-44/ reçoit un déroulé animateur, des fiches pratiques et un support projeté. Côté données, EpgvSeeder répartit les inscriptions sur des profils de règlement contrastés — 7 soldées, 2 partielles, 1 chèque annoncé mais non encaissé, 2 sans aucun paiement — pour que le tableau de bord de démonstration affiche 3 impayés plutôt qu'un jeu uniformément soldé, qui ne montrait aucun des écrans de relance. createPaymentReceived() et createPaymentPending() acceptent désormais des paramètres optionnels.
  • Comptes et réunion de formation du tenant demo-epgv-44 (#275, 2026-08-27). Six comptes admin (mot de passe password), un helper createEmployeeUser(), et la réunion « Présentation et formation Cohézio » avec ses six participants : la démonstration part d'un tenant peuplé au lieu d'écrans vides.

Corrections

  • Les compteurs du tableau de bord ignoraient le statut des inscriptions (#278, 2026-09-01). seasonRegistrations, unpaidCount et unpaidMembers passent par deux helpers privés qui appliquent whereNotIn('status', RegistrationStatus::terminalStatuses()). Mesuré sur une copie de la production : 527 inscriptions de saison annoncées pour 467 réelles, et 69 impayés annoncés pour 13. Un dirigeant relançait donc cinq fois trop de monde. collectedAmount n'est volontairement pas filtré : un paiement received reste encaissé après l'annulation de l'inscription qui l'a motivé — ne pas « compléter » ce correctif en l'y ajoutant.
  • Une inscription annulée interdisait toute réinscription du même membre sur la même campagne (#279, 2026-09-03). La contrainte UNIQUE totale (member_id, campaign_id) est remplacée par un index unique partiel, where status not in ('cancelled', 'abandoned'). Mesuré sur lezards-animes : 60 couples (membre, campagne) ne portaient plus que des inscriptions annulées — 60 personnes bloquées, avec un SQLSTATE 23505 pour seule explication. RegistrationDuplicateChecker est aligné sur terminalStatuses() ; c'est un no-op fonctionnel aujourd'hui, la méthode ne rendant que cancelled — l'index partiel EST le correctif. abandoned est nommé d'avance à dessein : status est un varchar(255) et non un enum PostgreSQL, l'ajouter à terminalStatuses() suffira le jour venu.
  • Findings de la revue de code du registre des salariés (#281, 2026-09-02). Treize sur quatorze corrigés. Le tableau de bord vide désormais unpaidMembers, recentActivity et closingSoonCampaigns côté serveur pour les rôles sans view_members : un coach recevait la charge utile complète et on ne comptait que sur le front pour ne pas l'afficher. mapPerson() est réduit à id / prénom / nom / e-mail, avec un test qui fige la forme exacte de la charge utile (sans etc()), pour qu'un champ rajouté fasse rougir la suite. Sont également corrigés : la double invitation sur une même fiche salarié, l'unicité d'un participant par réunion, l'eager-load de participants.person dans GenerateMeetingReportJob, le plafonnement de unlinkedStaff(), la validation d'employee_id en query-string comme UUID du tenant, et la seconde sortie de connexion de Fortify — celle du 2FA — qui ne redirigeait pas le coach.
  • La liste des adhérents annonçait « En attente » à des adhérents dont l'adhésion court (#283, 2026-09-04). Le badge et la pilule de filtre portant l'axe administratif (members.status) sont retirés de members/Index.vue et de MemberFilters.vue ; il ne reste que l'état dérivé des périodes de memberships. Motif : aucun écran n'écrit cette colonne — ni la création, ni le formulaire d'édition, et les boutons Désactiver / Réactiver touchent is_active —, si bien qu'elle restait figée sur sa valeur de création. Le trou est dans le front, pas dans le domaine : Member::changeStatus(), UpdateMemberUseCase et la route members.update-status savent l'écrire dès que la charge utile porte le champ. Le paramètre status reste donc honoré côté serveur et applyFilters le reconduit d'une requête à la suivante, sans quoi un signet le portant n'aurait survécu qu'à un seul chargement. PersonMemberTab.vue affiche encore le badge hérité : hors périmètre, connu. MembersLegacyStatusAxisHiddenTest verrouille le retrait sur le source — le réexposer suppose d'abord qu'un écran pose la valeur.

Notes techniques

  • Cette version est d'abord un rattrapage de documentation. Les sept premières PR ci-dessus sont parties en production entre le 27 août et le 3 septembre 2026 sans commit de release ; chacune est datée de son merge. Seule #283 est livrée par cette version : elle n'était pas déployée quand ce fichier a été écrit, et c'est pour ne pas reproduire le retard de documentation qu'il vient de combler qu'elle y figure. Les sept premières sont regroupées sous un seul numéro plutôt que réparties sur des versions intermédiaires que VERSION n'a jamais portées et qu'aucun déploiement n'a servies. Le segment mineur bouge parce que le registre des salariés ajoute des tables et un module.
  • Course::coachEmployee() a été renommée findActiveCoachEmployee(), et ce n'est pas cosmétique. Une méthode au nom de relation qui rend un Employee au lieu d'une Relation fait appeler getResults() par Eloquent dès qu'un eager-load ou un accès en propriété la croise : erreur fatale, pas avertissement.
  • Le down() de la migration de l'index partiel lève une RuntimeException. Restaurer une contrainte UNIQUE totale échouerait dès qu'un couple (membre, campagne) porte une inscription annulée et une active — soit exactement ce que cette version rend possible. La migration est à considérer comme irréversible en production.
  • recentActivity() n'est toujours pas filtré par statut : les inscriptions annulées continuent d'y figurer. Connu, hors périmètre de #278, à traiter avant que la saison 2026-2027 passe Active.
  • Employees/Form.vue expose encore person_id en UUID brut. Connu, non corrigé : le champ attend un sélecteur de personne.
  • Les PR #225, #237 et #266 à #269 ne sont listées nulle part ici : toutes ont été mergées entre le 21 et le 24 août 2026, donc hors de la fenêtre couverte par cette version. #225 et #237 sont par ailleurs des montées de dépendances Dependabot, que ce fichier ne détaille jamais — il documente l'activation de Dependabot et les gates de CI, pas les bumps individuels ; #266 à #269 sont des changements internes sans effet sur l'application (fichiers temporaires Livewire ignorés, notes de roadmap, retrait des références JetBrains, discipline de workflow).

0.24.0.0 — 2026-08-25

Ajouts

  • registrations:import {tenant} {file} --campaign=<uuid> --mapping=<json> [--apply] [--scan] importe en masse des inscriptions historiques depuis un export HelloAsso converti au format CSV pivot. Elle répond au rapatriement de Cholet Water-Polo (environ 700 inscriptions, échéance 2026-09-01). PivotCsvReader lit le fichier ligne à ligne en générateur, jamais chargé entier en mémoire ; TierMapping relie chaque libellé de tarif du fichier à une proposition existante ; ImportPayerResolver détermine le payeur et les représentants légaux des mineurs. ImportPreflight::build() est une fonction pure et en lecture seule qui produit un ImportPlan — par défaut la commande simule et affiche ce plan sans rien écrire ; --apply déclenche l'écriture, après confirmation interactive, par ImportRegistrationsUseCase, seul écrivain, sous verrou. L'écriture est idempotente au niveau panier : rejouer le même fichier après un échec partiel ne duplique rien et ne demande aucun nettoyage manuel. --scan profile un fichier (en-tête, distribution des tarifs) avant de lancer un import, sans exiger --campaign ni --mapping. Un test dédié garantit que la commande n'est jamais automatisée (ni post_build.sh, ni le planificateur) : c'est un outil d'opérateur, pas un job récurrent.
  • Le préflight confronte désormais chaque identité du fichier à la base, en lecture seule, avant d'autoriser la moindre écriture. Chaque adhérent est classé en quatre dispositions par rapport à ce que PersonDeduplicationService::findOrCreate() (INTOUCHÉ) retrouverait réellement : Réutilisée (fiche existante retrouvée à l'identique), Ambiguë — un e-mail est fourni, une fiche du même e-mail et du même prénom existe, mais la correspondance exacte échoue (casse d'e-mail ou date de naissance divergente) —, Aveugle — aucun e-mail dans le fichier alors qu'une fiche candidate existe déjà sur prénom/nom/date de naissance —, ou Créée. Ambiguë et Aveugle sont bloquantes ; un avertissement agrégé annonce « N adhérent(s) du fichier rejoindront une fiche existante, M seront créés. ». Motif : PersonDeduplicationService compare l'e-mail à la casse exacte près mais le prénom insensible à la casse, et ne cherche rien quand l'e-mail est nul — sans cette confrontation, l'import aurait créé des doublons en silence dans ces trois cas précis.
  • Une même proposition visée deux fois par un même adhérent dans le fichier, à des montants différents, fait échouer le préflight — jamais cumulée. attachPropositions() (ImportRegistrationsUseCase) ne conserve que le premier unit_amount vu par proposition et cumule les quantités : deux lignes à des libellés de tarif distincts pointant par erreur vers la même proposition auraient fait disparaître de l'argent en silence, sans qu'aucune contrainte SQL ne le détecte. validatePropositionAmounts() refuse ce cas en amont et détaille les lignes et montants en conflit — les cumuler (quantité × montant) aurait masqué l'incohérence de saisie au lieu de la signaler.
  • Les UUID de proposition passés via --mapping= sont normalisés en minuscules dans TierMapping. Str::isUuid() est insensible à la casse, mais propositions.id est stocké en minuscules (colonne uuid native sous PostgreSQL) : sans cette normalisation, un UUID saisi en majuscules passait la garde de forme puis échouait silencieusement le array_diff() (sensible à la casse) de validatePropositions(), rapporté à tort comme « introuvable ou hors de la campagne ». Le symptôme est le même sur les deux moteurs, seul le chemin diffère : sous PostgreSQL la colonne uuid native normalise la casse, la requête trouve donc la ligne et la rend en minuscules — c'est array_diff() qui la déclare ensuite absente ; sous SQLite, où id est du texte, c'est la requête elle-même qui ne trouve rien. Dans les deux cas l'opérateur lisait « introuvable » sur une proposition valide.

Notes techniques

  • Le cast Eloquent date sans format explicite a failli rendre la garde anti-doublon elle-même aveugle. Il sérialise l'écriture avec l'heure (2016-05-02 00:00:00) même pour une colonne date native : un whereIn() sur des chaînes AAAA-MM-JJ nues ne retrouve donc rien sous SQLite — comparaison de chaînes brute, invisible en CI, PostgreSQL n'ayant pas ce travers. Les requêtes de confrontation de la classification « Aveugle » utilisent whereDate(), la même fonction date(colonne) que PersonDeduplicationService::findOrCreate() applique déjà pour sa propre correspondance exacte — mêmes garanties cross-moteur. Sans ce choix, la garde anti-faux-négatif décrite ci-dessus aurait elle-même été un faux négatif : verte en CI, muette en production.

0.23.2.0 — 2026-08-20

Modifications

  • La liste des adhérents filtre désormais sur DEUX axes indépendants, et non plus sur un seul champ qui confondait les deux questions. L'axe administratif (members.status : En attente / Actif / Suspendu / Parti) décrit la relation du membre avec l'association ; l'axe adhésion (MembershipState : Adhérent / Adhésion à venir / Adhésion expirée / Non adhérent) est dérivé des périodes de memberships. Ils se combinent en intersection côté serveur, chacun avec son paramètre d'URL (status, membership). Le filtre unique précédent rendait « Adhésion expirée » indisponible dès qu'on voulait aussi trier sur « Suspendu ». Les deux axes coexistent définitivement : members.status n'est pas une étape transitoire de la migration, et les libellés le disent — statuses.active passe de « Adhésion active » à « Actif », statuses.suspended de « Adhésion suspendue » à « Suspendu ». Le vocabulaire d'adhésion est reversé dans un bloc de traduction distinct, membership_states.
  • Le filtre d'adhésion est un prédicat SQL, pas un tri après pagination. Deux portées ajoutées à Membership — startingAfter() et endedBefore() — complètent activeOn() pour reproduire en SQL la précédence de MembershipStanding::resolve() : Adhérent > À venir > Expirée > Aucune, bornes inclusives, end_date NULL jamais expirée (whereNotNull explicite plutôt qu'une comparaison où NULL déciderait), tenant_id explicite dans chaque sous-requête. Contrairement à activeOn(), les deux nouvelles portées sont des prédicats de date purs : elles ne filtrent pas les annulations, l'appelant compose avec active() — une période annulée ne doit jamais rendre un membre « adhésion à venir ». Filtrer en PHP après la pagination aurait rendu des pages incomplètes et un total faux.
  • ListMembersQuery évalue les deux axes sur UNE seule date. Un CarbonImmutable::now() est hissé en tête d'exécution et sert au filtre SQL comme à la projection ligne à ligne : sans lui, une page pouvait afficher un état que le filtre venait d'exclure. Une valeur d'axe inconnue n'invente aucun filtre — la liste reste complète plutôt que vide.
  • La cascade orpheline de memberships:sync ne devine plus, elle signale. La branche qui reconstituait une saison depuis members.membership_expires_at est retirée, avec SeasonIndex::endingExactlyOn() et le compteur « orphelins via date d'expiration ». Motif : la colonne n'a plus d'écrivain applicatif et disparaît de members à la version suivante — ancrer une période dessus revenait à la caler sur une valeur gelée. Il ne reste aucune source de déduction : un adhérent au statut actif sans inscription validée exploitable est reporté à l'opérateur. Aucune donnée n'est en jeu : la réconciliation ne peut annuler que les lignes héritées (season_id NULL), et un adhérent sans cible sort avant la réconciliation — les périodes déjà écrites par cette branche (6 adhérents en production, source Import, toutes porteuses d'un season_id) sont conservées telles quelles.

Retraits

  • MemberStatus::EXPIRED quitte l'enum. Une migration normalise d'abord les lignes résiduelles members.status = 'expired' vers 'active' : le champ est casté en enum PHP sur le modèle, une seule ligne survivante lèverait un ValueError fatal au premier accès en lecture. Volume constaté sur la copie anonymisée de production (2026-08-20) : 23 lignes, toutes portées par des tenants de démonstration (club-asso 12, demo-cholet-waterpolo 8, asso-culture 1, demo-ligue-56 1, demo-epgv-44 1) — aucune sur lezards-animes. Le down() est vide et documenté comme irréversible : la distinction expired/active n'est plus représentable sur cet axe. La migration écrit via DB::table() et non Eloquent, volontairement : le global scope BelongsToTenant restreindrait la requête au tenant courant — inexistant en migration — et ne balaierait aucune ligne. UpdateMemberRequest valide désormais avec Rule::enum(MemberStatus::class) au lieu d'une liste recopiée qui aurait continué d'accepter 'expired' en silence.
  • Les colonnes current_membership_year et membership_expires_at ne sont plus lues NI écrites par aucun chemin applicatif : retirées de l'entité de domaine Member, de MemberMapper, de MemberResponse, de MemberSectionData, du $fillable et des casts() du modèle Eloquent, des types TypeScript, des libellés de champ et des noms d'attribut de validation. Les six seeders de démonstration et MemberFactory cessent de les alimenter. Le DROP SQL est délibérément différé à la version suivante : post_build.sh lance migrate --force pendant que l'instance précédente sert encore le trafic, si bien qu'un dropColumn livré ici ferait lire des colonnes disparues au code encore en ligne (SQLSTATE 42703). Retirer les lectures et supprimer les colonnes doivent être deux déploiements distincts, dans cet ordre.

Notes techniques

  • Un test de partition garde l'équivalence filtre SQL ↔ statut affiché. Pour chaque état S, { ids rendus par le filtre S } doit égaler { ids dont l'état projeté vaut S } — et l'oracle est un execute() sans filtre, donc la projection réellement affichée sur la page, pas un appel direct au dépôt. La cohorte compte 24 adhérents sous horloge gelée, dont les cas de borne (période commençant le jour même, finissant le jour même, débutant demain, finie hier), les périodes sans fin, les recouvrements multiples et neuf combinaisons d'annulations. Trois assertions supplémentaires : l'union des quatre états couvre la cohorte et les états sont deux à deux disjoints ; aucune fuite inter-tenant ; les deux axes se combinent. Vérifié par mutation : desserrer la borne basse de activeOn() (<= → <) ou retirer ->active() de la sous-requête fait tomber le test dans les deux cas.
  • Une divergence SQL/VO est documentée et tenue hors cohorte. Une période inversée (start_date > end_date) est classée upcoming par la précédence SQL alors que le value object refuse de l'hydrater (InvalidMembershipPeriodException). Elle est inatteignable sur PostgreSQL — daterange lève SQLSTATE 22000 — donc sans effet en production, et seulement productible sur SQLite. Le cas est épinglé par un test dédié qui assère sur les portées, pas sur execute() : assérer l'exception aurait gravé un plantage dans le contrat de la liste.
  • vue-tsc ne garde pas le câblage d'un filtre. 'membership' manquait dans les deux appels request()->only() de MemberController : la pilule était inerte via HTTP et se réinitialisait au rechargement, sans qu'aucune des trois portes — PHPStan, vue-tsc, Pest — ne rougisse, le test de filtre appelant ListMembersQuery::execute() directement. Corrigé et couvert par un test de niveau HTTP, falsifié en le rejouant sur le code d'origine (Property [filters.membership] does not exist).
  • DemoSeedersAreIdempotentTest a été réécrit, pas supprimé. Les seeders écrivent désormais des périodes d'adhésion ; le décor construit à la main entrait en collision avec l'index unique partiel sur deux jeux de données et faussait un compte sur trois autres. Sa précondition est resserrée sur les saisons du tenant testé : un whereNotNull nu passait avec une période ancrée sur la saison d'un autre tenant, où la branche NO ACTION de la clé étrangère n'est jamais exercée.
  • vendor/bin/pint a de nouveau réécrit un PHPDoc en use. Sa règle fully_qualified_strings a transformé les références {@see} de la nouvelle migration en imports de classes. Contourné en citant les noms entre accents graves. Même piège qu'en 0.23.1.0, autre fichier.

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.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.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.21.0.0 — 2026-07-26

Ajouts

  • Alerte dashboard « campagnes bientôt clôturées » : le tableau de bord affiche désormais les campagnes d'inscription ouvertes dont la clôture programmée arrive dans les 7 jours, pour éviter de découvrir une fermeture automatique après coup.
  • Page de clôture dédiée pour les inscriptions publiques (/register/{token}/closed) : quand une campagne se ferme ou qu'un lien d'inscription expire, l'adhérent est redirigé vers une page qui explique clairement pourquoi, au lieu d'un message d'erreur générique.

Modifications

  • Message d'erreur convivial à la clôture d'une campagne : le motif de fermeture (campagne close vs lien expiré) est désormais dérivé côté serveur (CampaignPublicToken::closedReason()) plutôt que déclaré par le client, ce qui empêche un motif erroné ou falsifié d'être affiché. En cas de cumul des deux causes, le message priorise le lien expiré.
  • L'endpoint d'estimation tarifaire (/register/{token}/estimate) répond désormais systématiquement en JSON 410 quand la campagne se ferme en cours de saisie, au lieu de rediriger vers une page HTML.