Journal des modifications
Les évolutions de Cohez.io, de la plus récente à la plus ancienne.
18 versions correspondent à « relance ».
Versions 1 à 10 sur 18
0.25.11.0 — 2026-09-29
Ajouts
- Aide en ligne : rubrique Paniers & paiements (#333, 2026-09-29). Six guides pour les trésoriers et dirigeants : comprendre un panier, versements, paiement manuel, paiement en ligne HelloAsso, relances, annuler une inscription. Les guides décrivent le comportement actuel, limites comprises (pas de remboursement par Cohez.io, versement corrigé par rejet, pas de paiement en plusieurs fois HelloAsso). L'accueil de l'aide ajoute l'étape « Choisir les moyens de paiement » avant l'ouverture, et les pages Inscriptions renvoient vers la nouvelle rubrique. Chemins figés par des tests ; captures dans une PR suivante.
0.25.5.0 — 2026-09-25
Ajouts
- Les pièces santé sont rangées par saison, et la fiche adhérent affiche un bloc de conformité par saison active (#317, 2026-09-25). Une pièce appartient désormais à une saison : clé
(member_id, season_id, document_type), une attestation par(adhérent, saison), avec la campagne d'inscription quand elle est connue (member_documents.campaign_id).SeasonHealthProofest l'oracle unique d'une saison (certificat courant + historique) etGoverningCampaignResolverraisonne par saison. Dans une saison, le certificat auvalid_untille plus tardif reste courant ; un dépôt qui expire plus tôt entre en historique, et le remplacement ne supprime jamais de fichier. Le back-office choisit explicitement la saison (et la campagne) au dépôt ; dépôt et retrait sont refusés (404) sur une saison close. Un certificat déjà expiré à la date du dépôt est signalé (CertificateState, statutStaleCertificate). Le tunnel public n'échoue plus sur une seconde attestation pour la même saison. - Page « Archives santé » : consultation motivée et journalisée des pièces des saisons closes (#317, 2026-09-25). Nouvelle permission nominative
view_archived_health_documents, accordée par utilisateur (case « Archives santé » dans les réglages utilisateur) et jamais attribuée par défaut, même au rôleadmin. Ouvrir une pièce archivée exige un motif d'au moins 10 caractères, journalisé dansmember_document_accesses(reason,context = archive) avant l'envoi du premier octet ; 403 sans permission, 422 sans motif, 404 hors phase archivée,AdminUserrefusé. health:unanchored-documentsliste les pièces santé sans saison et permet de les rattacher (--attach) (#317, 2026-09-25). Lecture seule par défaut ; ces pièces ne sont jamais purgées automatiquement.
Modifications
- La conservation des pièces santé est une durée par saison : fin de saison + N ans (#317, 2026-09-25).
tenants.health_retention_years(5 par défaut) etHealthRetentiondéfinissent trois phases — active, archivée, supprimée — utilisées partout (fiche, téléchargements, archives, purge).health:purge-documentsne purge plus à l'expiration du certificat (+ 30 jours) : elle supprime fichiers, lignes et attestations des saisons arrivées en fin de conservation, par lots, tenant par tenant. Le fichier est supprimé avant sa ligne : un échec de stockage laisse la ligne en place et la suppression est retentée la nuit suivante. La commande renvoieFAILUREdès qu'un tenant échoue, et la tâche planifiée est protégée contre le chevauchement. ⚠️ Une date de fin de saison erronée dans le passé déclenche une purge prématurée et irréversible. - Migration de données (#317, 2026-09-25). Les pièces existantes sont rattachées à leur saison, les doublons par saison résolus par supersession, les index uniques posés par saison. La réponse d'attestation « au moins une positive » (
has_positive) est retirée : une réponse positive appelle un certificat.
Notes de déploiement
- En production,
post_build.shrelancePermissionSeederetRoleSeeder: rien à faire. Sur une base de développement non re-seedée, la nouvelle permission manque et la page des réglages utilisateur renvoie une 500 (PermissionDoesNotExist) : relancer ces deux seeders.
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).
PaymentInstructionsResolverremplaceBankTransferInstructionsResolver— supprimé sans résidu — et produit unPaymentInstructionsSetde 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 VuePaymentInstructions.vue. Auparavant, seul le virement produisait des instructions ; chèque, espèces, carte hors ligne, HelloAsso et moyen personnalisé n'en affichaient aucune. - La
descriptiond'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ésormaisPaymentMethod::CreditCard(« Règlement par carte bancaire sur place, auprès de l'association. ») dePaymentMethod::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_methodsne porte pas encore de champ dédié au destinataire ;PaymentInstructionsResolver::checkPayee()rendtenant->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_methodest 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 viaapplicable_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 deCampaignDepositRule, qui décrivaitsettlement_methodcomme 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.
CartPaymentReminderMailcalcule 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).
whereInsur des UUID ne garantit aucun ordre : le panier est désormais trié parmember_index, et la ligne représentative choisie par le nouveau helperCartRepresentative::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
Validatedet soldé (hasOutstandingBalance()àfalse) fait court-circuiterPaymentInstructionsResolver::forCart()enPaymentInstructionsSet::empty(): aucun bloc d'instructions n'est rendu, volontairement — pas de paiement dû, pas d'instructions. Un panierValidatedmais 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-1est 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
TenantPaymentMethoddePaymentInstructionsResolver::resolveMethodKey()ne filtraient niis_enabledniis_public; elles passent désormais par le scopepublic(), le même que le reste de la surface publique. Le canal le plus exposé estsettlement_method: il est choisi par l'admin dansCampaignDepositRule::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. AucunorderByn'est ajouté : l'index partieluq_tenant_std_paymentgarantit 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)dePaymentInstructionsResolver::forCart()rendaitPaymentInstructionsSet::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,PublicCartConfirmationMailcalculait sa ligne « Moyen de paiement » de son côté et une panne ne coûtait que le bloc RIB. Lecatchrenvoie 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.PublicCartConfirmationMailcharge en outrepaymentsexplicitement :$settledNoticeappellehasOutstandingBalance(), 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
employeeset pivotemployee_employee_types— un salarié cumule plusieurs types —, enumsEmployeeTypeetEmployeeStatus,EmployeePolicy, pages InertiaEmployees/Index|Show|Form.users.employee_id(nullable) relie un compte à sa fiche RH ;meeting_participants.person_idajoute un quatrième mode de participation, mutuellement exclusif des trois autres. Un rôlecoachet une permissionview_own_coursesouvrent 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()exposeunlinkedStaff— les comptes du tenant dontemployee_idest 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é-remplitperson_idque si un seulpersons.emailcorrespond (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,EpgvSeederré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()etcreatePaymentPending()acceptent désormais des paramètres optionnels. - Comptes et réunion de formation du tenant
demo-epgv-44(#275, 2026-08-27). Six comptesadmin(mot de passepassword), un helpercreateEmployeeUser(), 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,unpaidCountetunpaidMemberspassent par deux helpers privés qui appliquentwhereNotIn('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.collectedAmountn'est volontairement pas filtré : un paiementreceivedreste 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é surlezards-animes: 60 couples (membre, campagne) ne portaient plus que des inscriptions annulées — 60 personnes bloquées, avec unSQLSTATE 23505pour seule explication.RegistrationDuplicateCheckerest aligné surterminalStatuses(); c'est un no-op fonctionnel aujourd'hui, la méthode ne rendant quecancelled— l'index partiel EST le correctif.abandonedest nommé d'avance à dessein :statusest unvarchar(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,recentActivityetclosingSoonCampaignscôté serveur pour les rôles sansview_members: uncoachrecevait 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 (sansetc()), 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 departicipants.persondansGenerateMeetingReportJob, le plafonnement deunlinkedStaff(), la validation d'employee_iden 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 demembers/Index.vueet deMemberFilters.vue; il ne reste que l'état dérivé des périodes dememberships. Motif : aucun écran n'écrit cette colonne — ni la création, ni le formulaire d'édition, et les boutons Désactiver / Réactiver touchentis_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(),UpdateMemberUseCaseet la routemembers.update-statussavent l'écrire dès que la charge utile porte le champ. Le paramètrestatusreste donc honoré côté serveur etapplyFiltersle reconduit d'une requête à la suivante, sans quoi un signet le portant n'aurait survécu qu'à un seul chargement.PersonMemberTab.vueaffiche encore le badge hérité : hors périmètre, connu.MembersLegacyStatusAxisHiddenTestverrouille 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
VERSIONn'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éefindActiveCoachEmployee(), et ce n'est pas cosmétique. Une méthode au nom de relation qui rend unEmployeeau lieu d'uneRelationfait appelergetResults()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 uneRuntimeException. 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 passeActive.Employees/Form.vueexpose encoreperson_iden 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.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 remplacememberships:backfill, qui ancrait les périodes surcreated_atsans 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 pureSyncMemberMembershipsUseCase::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 sansend_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, pasmembers.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, etmemberships.end_dateNULL 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 deuxseason_idcibles 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_datecorrespond exactement àmembership_expires_atet écrit cette saison pleine avecsource = Import. Il n'existe pas de repli surmembers.status: un adhérentACTIVEsans 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. Etmembers.statusne prouve pas l'adhésion : aucun écrivain applicatif ne le rétrograde,MemberStatus::EXPIREDn'é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:syncn'est jamais automatisée (tests/Feature/Console/MembershipCommandsNotAutomatedTest.php) : niclevercloud/post_build.sh, nipre_build.sh, nicron.sh, nicron.json, niroutes/console.php, nibootstrap/app.phpne 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.
CreateMembershipFromRegistrationListenerne consulte plusMembershipSettingsniMembershipPeriodCalculator: il résout la saison viaregistration.campaign.seasonet appelleMembershipPeriod::forSeason(seasonStart:, seasonEnd:, anchor: $event->validatedAt()). La règle est celle arrêtée en conception :fin = saison.fintoujours,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 gardeMembershipSettings::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. Leseason_idetcreated_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. RegistrationValidatedtransporte l'instant de validation. L'événement portevalidatedAtet les six sites de dispatch (CancelRegistrationService,SettleFreeCartService,VersementService,RegistrationTransitionController,HelloAssoPaymentConfirmationService,BackfillCancelledAllocationsCommand) le renseignent — un seulCarbonImmutable::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::LIFETIMEdisparaî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'étatMemberFactory::lifetime()(aucun appelant) et la traduction « À vie ». Motif : uneend_dateNULL contredit frontalement l'invariant du moteur — une période vit dans sa saison — et une seule ligne suffisait à faire refusermemberships:syncsur le tenant entier, sans issue automatique. La colonnemembers.subscription_typeest conservée, l'énumération aussi, à un seul cas — non pas comme point d'extension du régimerolling_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.SubscriptionTypeest l'axe de l'adhérent — quelle formule il a souscrite — et ne calcule aucune borne, sous aucun régime.UpdateMemberRequestvalide désormais avecRule::enum(SubscriptionType::class)au lieu d'une listeRule::in(...)recopiée à la main, qui aurait continué d'accepter'lifetime'en silence. Côté front,SubscriptionType(TypeScript) et les typesMember/Personsont réduits au même cas unique.MembershipFactory::lifetime()est renomméeunbounded()— 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(calculfixed_period/rolling_year), le VOMembershipSettings(configuration d'adhésion du tenant) et la commandememberships:backfillsont 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, etmemberships:syncremplace le rattrapage. L'enumMembershipRenewalTypeest conservée comme point d'extension documenté (seasonseul mode implémenté,rolling_yearré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), deAssociationController(props Inertia et écriture desettings), desettings/Association/Edit.vueet des clés de traduction (lang/fr/settings.php,resources/js/types/translations.d.ts). Ce n'était pas un simple champ inerte : la gardeisset($validated['membership_renewal_type'])était toujours vraie puisque le formulaire postait la valeur, si bien que chaque sauvegarde de la page réécrivaitfixed_period+1/1danssettings— un réglage menteur que plus rien ne lisait.AssociationController::update()ne touche désormais plus du tout à la colonnesettings.AssociationMembershipSettingsTestest 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 desettingssont préservées. Member::scopeExpired()etMember::scopeExpiringSoon()sont supprimés, ainsi que leurs annotations@method. Ils interrogeaientmembers.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 lignesmemberships. Aucun appelant n'existait.
Notes techniques
- La propriété
validatedAtest nullable avec valeur par défaut, et ce n'est pas cosmétique.SendRegistrationValidatedEmailListener implements ShouldQueue: l'événement est sérialisé en file, etSerializesModels::__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 denulldont tout accès lève uneError, qu'un simple??ne rattrape pas. Le défautnullrend cet état structurellement impossible et l'accesseurvalidatedAt()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 «
RegistrationValidatedest dispatché àDB::transactionLevel() === 0» reste la condition de sûreté de l'écouteur. Soncatch (\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.MembershipDispatchTransactionLevelTestcouvre désormais quatre chemins au lieu de deux (CancelRegistrationService::canceletHelloAssoPaymentConfirmationService::confirms'y ajoutent), chacun vérifiant que l'inscription atteint bienValidatedet 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_idestNULL(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 surclub-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 donctoHaveCount(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()prendlockMember()avant toute lecture, puis annule et écrit dans la même transaction. L'écriture passe parRecordMembershipPeriodUseCase, seul écrivain : sa transaction imbriquée devient un SAVEPOINT, ce dont dépend la traduction d'un 23P01 sans empoisonner la transaction englobante. Uncatch (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é parid. UnMIN(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. SeasonIndexfiltretenant_idexplicitement. En console,TenantContextest nul et le scope global deBelongsToTenantest 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 saisondraftest 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 delezards-animesa recueilli ses inscriptions validées entre mai et août 2026 sur une saison2026-09-01 → 2027-08-31restéedraft. Un filtre sur le statut priverait donc d'adhésion toute une campagne.SeasonFactoryproduisantDraftpar 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 saisonsdraftdans l'écouteur fait tomber ce test. OverlappingMembershipExceptionest journalisée enwarning, pas enerror, 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 enerroravecregistration_id. Une inscription sans membre ou sans saison résolvable sort enerrorsans écrire. Cewarningétant la seule trace de l'anomalie, son contexte portemember_idetseason_iden plus deregistration_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 — colonnesNOT NULL— et non sur les variables assignées à l'intérieur dutry, 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
Membertransforme toute valeur'lifetime'restée en base enValueErrorfatale, sur tout chemin de lecture d'un adhérent — fiche, liste, mapper. La migrationnormalize_lifetime_subscription_typeréé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, tousannual, et zéromemberships.end_dateNULL) : elle couvre la fenêtre entre cette mesure et le déploiement, pendant laquelleUpdateMemberRequestacceptait encore'lifetime'. Elle passe parDB::table()et non Eloquent —Memberporte le scope globalBelongsToTenant, qui hors contexte tenant ne toucherait aucune ligne, alors qu'une migration doit balayer tous les tenants. Sondown()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.20.2.0 — 2026-07-23
Ajouts
- Pull backup prod Clever Cloud + anonymisation automatique de la base de dev :
scripts/pull-prod-backup.shtélécharge le dernier backup Postgres Clever Cloud de la prod, le restaure dans le conteneur Docker local, puis anonymise automatiquement toutes les données à caractère personnel via la nouvelle commandedb:anonymize— jamais d'étape manuelle séparée, jamais de vraie donnée personnelle qui atterrit en dev même transitoirement. Scramble déterministe (Faker seedé par ligne, idempotent) des colonnes structurées (persons,users,payeurs,partner_contacts,meeting_participants,invitation_tracking,registration_relance_emails,legal_guardians,meeting_actions,meetings), vidage du texte libre (transcriptions, notes, comptes-rendus) vers un placeholder générique, troncature des tables éphémères (sessions,password_reset_tokens). Garde-fou dur :db:anonymizerefuse de s'exécuter en environnementproduction, sans option de contournement. L'anonymisation est garantie même en cas d'échec partiel de la restauration (déclenchée par untrapsur la sortie du script, pas en séquence normale) ; un mode--dry-runpermet de vérifier le backup sélectionné sans rien télécharger ni exécuter. L'image Postgres locale (docker/postgres/Dockerfile) embarque désormaispostgis/pgvector, requis par le dump prod Clever Cloud (addon avec ces extensions activées par défaut) — sans quoipg_restoreéchouait avant même d'atteindre l'anonymisation.db:anonymizebascule aussi le mot de passe de tous lesuserssur"password"(hash bcrypt), pour permettre la connexion en local après restauration sans avoir accès au mot de passe prod original.admin_users(comptes internes Code Ethic nécessaires à l'administration de la plateforme) reste volontairement hors du périmètre d'anonymisation.
Corrections
- Emails scramblés par
db:anonymizepouvaient entrer en collision :Faker::safeEmail()tire d'un espace de valeurs fini malgré le seed déterministe par ligne, risquant de violerunique(tenant_id, email)sur un backup prod de taille réelle. L'email généré est désormais suffixé par un hash de l'id de ligne (clé primaire, garantie unique par table).
0.19.1.0 — 2026-07-06
Modifications
- Découpage du contrôleur des inscriptions par ressource HTTP : le
RegistrationControllermonolithique (2 170 lignes, 27 actions) est scindé en 6 contrôleurs dédiés — CRUD inscriptions, transitions de statut (valider/refuser/annuler), gestion de cours (ajout/retrait/échange + aperçus), panier (détail, paiement, facture PDF, membres, notes), relances et export CSV. Aucun changement de comportement : URLs, noms de routes et autorisations strictement identiques (déplacements verbatim vérifiés mécaniquement). - Les aides partagées (filtres de liste, représentant de panier, options de campagne, noms de cours) deviennent des traits réutilisables
Concerns/, et la liste des raisons comptant comme « relance manuelle » est centralisée sur l'énumRelanceReason::MANUAL_REASONS(valeurs inchangées).
Ajouts
- Test unitaire de l'énum
RelanceReason: libellés et contenu exact deMANUAL_REASONSdésormais verrouillés.
0.19.0.0 — 2026-07-03
Ajouts
- Lien de paiement en ligne pour panier offline impayé : depuis le détail panier, un bouton du panneau de relance génère un lien de paiement HelloAsso pour un panier initialement réglé hors ligne (chèque, virement, espèces) qui reste impayé — sans obliger l'adhérent à repasser par tout le formulaire. Guardé par token public valide, solde panier > 0 relu sous verrou, et HelloAsso configuré pour le tenant (le bouton est masqué sinon).
- Verrou de checkout HelloAsso anti double-clic/double-onglet :
Cache::lockpar panier (30s, attente 5s) sérialise les checkouts concurrents sur le même jeu d'inscriptions ; le solde est relu sous verrou juste avant chaque appel HelloAsso, avec dégradation gracieuse (HelloAssoAlreadySettledException) si un règlement concurrent a déjà tout soldé. Les appels HTTP HelloAsso sont bornés à 10s pour ne jamais dépasser la durée du verrou.
Corrections
- Le montant réclamé par la relance et le checkout HelloAsso se base désormais sur le solde du panier (
VersementService::cartOutstandingCents), plus sur la ventilation par inscription — élimine les relances erronées après une re-ventilation des paiements à l'annulation. - Élimination de plusieurs N+1 (
registration.payments,registration.balance) dansHelloAssoRelanceServiceet le flux de retry HelloAsso. - Exclusion des paiements HelloAsso fantômes de la relance panier :
payableCartPayments()(etcanSendManualRelance(),createOnlinePaymentLink()) retournent vide dès que le panier n'a plus de solde dû au niveau versement (VersementService::cartOutstandingCents), même si unPaymentPending/Rejectedrésiduel survit sur une inscription réglée hors ligne entre-temps ; garde sur destinataire absent ; resynchronisation dePayment.amountau moment du checkout.
0.16.32.0 — 2026-06-25
Ajouts
- Relancer le paiement au niveau du panier. La page « Détail du panier » propose désormais un bouton « Relancer le paiement » qui s'adresse au payeur pour l'ensemble du panier, en un seul envoi. Le mode est choisi automatiquement : si un paiement HelloAsso reste à régler, un lien de paiement est renvoyé ; sinon (chèque, virement, espèces) un rappel récapitulatif liste le solde restant dû de chaque inscrit, le total et la date limite la plus proche. Une fenêtre de confirmation affiche le destinataire et prévient si une relance a déjà été envoyée il y a moins de 24 h. L'historique des relances du panier est visible sur la page. Le panneau est masqué quand le panier est soldé et pour un compte en lecture seule ; l'isolation entre associations est garantie (un panier d'une autre association renvoie une 404).
0.16.30.0 — 2026-06-24
Modifications
- Gérer une inscription sans quitter le panier. Les actions par inscription (valider, demander la validation, refuser, annuler, ajouter / retirer / échanger un cours, prolonger l'acompte, envoyer le reçu) s'effectuent désormais directement dans l'accordéon de chaque inscrit sur la page « Détail du panier ». La fenêtre d'aperçu des montants (ajout / retrait / échange de cours) s'ouvre sur place, et chaque action rafraîchit les données sans changer de page. Le panier devient le poste de travail unique du gestionnaire.
- Fiche inscription recentrée sur la consultation. La page d'une inscription conserve l'affichage détaillé (facture, notes, historiques) et la carte de relance, mais n'héberge plus les actions migrées ; le lien depuis le panier devient « Voir le détail ».
- Aucune action visible pour un compte en lecture seule. Un utilisateur disposant uniquement de la consultation des adhérents ne reçoit aucun contrôle d'action sur le détail du panier (parité avec les contrôles côté serveur).