MON-SEC-001B — UUID canonique pour plan_id
Statut
Mergé — dmv_api PR #34, SHA squash 5c9e0a6 (base main).
Périmètre
Ferme la dette plan_id reportée par DB-001 et MON-SEC-001A : tout le code Laravel utilise
désormais subscription_plans.id (UUID) comme valeur persistée dans actor_subscriptions.plan_id
et subscription_logs.plan_id. Le slug reste l'identifiant public/API là où le contrat le
requiert (plan_slug dans subscribe()). Arbitrages déjà figés non rouverts : snapshot courant,
UNIQUE(acteur_id), UUID canonique (décision architecte préalable à cette mission),
subscription_logs comme historique séparé.
1. État avant
actor_subscriptions.plan_id et subscription_logs.plan_id étaient varchar(50) en local,
contenant des slugs (subscription_plans.slug), alors que le schéma réellement déployé (deux
dumps de référence 2026-08-09/2026-08-13, identiques) porte :
actor_subscriptions.plan_id:uuid NOT NULL, FK réelleactor_subscriptions_plan_id_fkeyverssubscription_plans(id).subscription_logs.plan_id:uuidnullable, sans FK (vérifié sur les deux dumps — le script SQL de conception du 2026-04-10 en suggérait une, mais les dumps, seule preuve de l'état réellement déployé, n'en montrent aucune).
2. Cartographie (Phase A)
Recherche exhaustive (grep -rln sur app/, database/, tests/) avant toute modification.
Writers actor_subscriptions.plan_id : ensureFreeSubscription(), subscribe(),
onSubscriptionCreated(), onSubscriptionDeleted() (+ primitif upsertActorSubscription()),
AdminSubscriptionWriteService::changePlan()/grantPlan().
Writers subscription_logs.plan_id : appendLog() (Monetization), log() (Admin) — tous
deux de simples passe-plats recevant déjà la valeur résolue par l'appelant.
Readers joignant subscription_plans.slug = *.plan_id : MonetizationReadService::canAddTag/ canAddDocument, MonetizationWriteService::syncActorBadge(), AdminSubscriptionWriteService:: changePlan() (lecture de l'ancien plan) / getHistory(), AdminSubscriptionService:: listSubscriptions()/listChanges(), AdminStatsController (2 jointures).
Déjà corrects (écrits en supposant UUID, donc déjà « bons » mais silencieusement vides tant que
plan_id stockait un slug) : AdminDashboardController (« subs by plan », « recent plan
changes »), le filtre ?plan_id= de AdminSubscriptionService::listSubscriptions() côté
frontend (dmv-backoffice/src/pages/Souscriptions.jsx), le contrat admin frontend au complet
(dropdowns <option value={p.id}>, endpoints changePlan/grantPlan déjà validés
required|uuid).
Relations Eloquent : ActorSubscription::plan(), SubscriptionPlan::subscriptions(),
SubscriptionLog::plan() — toutes belongsTo/hasMany(..., 'plan_id', 'slug') → 'id'.
Non touchés, usages légitimes du slug (vérifiés, pas de persistance plan_id) :
AISubscriptionQuotaSync::sync(), PlanQuotaSync::sync() (lookup subscription_plans par slug
pour synchroniser des quotas IA/push — jamais actor_subscriptions.plan_id/subscription_logs. plan_id), AdminPlanService (CRUD sur subscription_plans lui-même), SubscribeRequest
(plan_slug, contrat public d'entrée), AIQuotaService::resolvePlanLimit() (tolérance
where('slug', ...)->orWhere('id', ...), conservée intacte — voir §10).
Aucun usage ambigu rencontré — aucun STOP déclenché sur ce point.
3. Compatibilité API/frontend
Recherche exhaustive de tout consommateur de plan_id dans les réponses JSON, avant toute
décision (grep -rln "plan_id" dmv-public/src dmv-workspace/src dmv-backoffice/src) :
dmv-public/dmv-workspace: aucune occurrence. L'endpoint publicGET /acteurs/{id}/subscription(ActorSubscriptionDTO) exposeplan_idmais aucun frontend ne le consomme.dmv-backoffice(seul consommateur) : les 4 fichiers concernés (Souscriptions.jsx,Abonnements.jsx,Circonstances.jsx,ActeurDetailModal.jsx) utilisent déjà<option value={p.id}>(UUID) pour les dropdowns de sélection de plan, et soumettent déjà ce même UUID àPATCH .../planetPOST .../grant, endpoints déjà validés côté serveur enrequired|uuid. Le contrat d'écriture admin est donc déjà UUID — inchangé par cette mission.- Le pré-remplissage en lecture (
setEditPlanId(s.plan_id ?? "")) recevait auparavant un slug qui ne correspondait jamais à un<option value={uuid}>(dropdown toujours vide par défaut, bug UX pré-existant mineur) — il correspond désormais correctement, amélioration, pas régression.
Aucun STOP déclenché : aucun frontend ne dépend du format slug pour plan_id. Décision
documentée ici plutôt qu'appliquée silencieusement : ActorSubscriptionDTO expose désormais
l'UUID comme toute autre persistance de plan_id, sans traduction — cohérent avec la « règle
finale » de la mission (le code Laravel et le schéma déployé parlent le même langage).
4. Writers corrigés
Tous résolvent désormais l'UUID (subscription_plans.id) avant persistance :
ensureFreeSubscription(): nouveau helper privéresolveFreePlanId()(lookupsubscription_plans.slug = 'free'→.id, lève\RuntimeExceptionsi absent — configuration invalide, ne doit jamais se produire hors base mal amorcée).subscribe(): utilisait déjàSubscriptionPlan::where('slug', $planSlug)->firstOrFail()— persiste désormais$plan->idau lieu de$planSlug.onSubscriptionCreated():$plan?->id ?? $this->resolveFreePlanId()(au lieu de$plan?->slug ?? 'free').onSubscriptionDeleted():$this->resolveFreePlanId()(au lieu de'free').upsertActorSubscription()(primitif) : fallback interne?? $this->resolveFreePlanId()(au lieu de?? 'free') — sécurise le cas anormal où aucune ligne n'existe et aucunplanIdn'est fourni.AdminSubscriptionWriteService::changePlan()/grantPlan(): persistent$plan->id(au lieu de$plan->slug) ;quotaSync->sync()continue de recevoir des slugs (paramètre distinct, jamais lié à la persistanceplan_id).
5. Readers corrigés
Toutes les jointures subscription_plans.slug = ...plan_id deviennent subscription_plans.id = ...plan_id : MonetizationReadService::canAddTag/canAddDocument, syncActorBadge(),
AdminSubscriptionWriteService::changePlan() (lecture ancien plan) / getHistory(),
AdminSubscriptionService::listSubscriptions()/listChanges(), AdminStatsController (2
occurrences). Les comparaisons sur subscription_plans.slug elle-même (ex. where('subscription_ plans.slug', '!=', 'free'), filtrant la colonne réelle du plan joint) restent inchangées — ce
n'est pas une comparaison de plan_id.
6. Relations corrigées
ActorSubscription::plan(), SubscriptionPlan::subscriptions(), SubscriptionLog::plan() —
belongsTo/hasMany(..., 'plan_id', 'id'). PHPDoc mis à jour en conséquence.
7. subscription_logs
Writers et readers alignés en même temps que actor_subscriptions — jamais un état
transaction+log incohérent (UUID d'un côté, slug de l'autre) : appendLog()/log() reçoivent
déjà la valeur résolue par l'appelant, donc automatiquement UUID partout. Schéma de test aligné
(§9).
8. AdminDashboardController
Non modifié dans son code — where('plan_id', $plan->id) et
leftJoin('subscription_plans as p', 'p.id', '=', 'l.plan_id') étaient déjà écrits en supposant
l'UUID (bug silencieux documenté par MON-SEC-001, jamais un défaut de ce contrôleur lui-même).
Corrigé par construction dès que plan_id devient réellement UUID.
Non testé de bout en bout dans cette mission — voir dette §11 (subscription_plans diverge très
au-delà de plan_id, colonnes active/ordre absentes localement, bloquant la requête « subs by
plan »). Le motif de jointure exact utilisé par « recent plan changes »
(subscription_logs.plan_id = subscription_plans.id) est prouvé correct par le test
get_admin_subscriptions_id_history_resout_plan_nom_via_uuid, qui exerce le même motif via
AdminSubscriptionWriteService::getHistory().
9. Migrations test
Deux migrations append-only, idempotentes, gardées, jamais appliquées à Supabase :
2026_08_17_000003_align_plan_id_columns_to_uuid.php—actor_subscriptions.plan_id:varchar(50)→uuid NOT NULL+ FK verssubscription_plans(id).subscription_logs.plan_id:varchar(50)→uuidnullable, sans FK (fidèle aux deux dumps).2026_08_17_000004_add_missing_columns_to_subscription_logs_table.php— ajouteadmin_note,admin_user_id,periode,montant_htàsubscription_logs(toutes présentes et nullables dans le schéma réel, absentes du schéma de test) — voir §10 pour le contexte de cette découverte annexe.
Aucune migration historique modifiée. Aucune migration destinée à Supabase (garde applicative existante, migrations Laravel jamais exécutées contre la production).
10. Bug découvert et corrigé — stripe_invoice_id inexistant
En écrivant les tests requis pour changePlan()/grantPlan(), AdminSubscriptionWriteService:: log() échouait systématiquement : il insérait inconditionnellement une clé stripe_invoice_id
dans subscription_logs, colonne absente des deux dumps de référence (2026-08-09,
2026-08-13, identiques). Ce n'est pas une divergence de schéma de test — la colonne n'existe pas
non plus en production réelle. Tout appel à changePlan(), grantPlan(), createInvoice()
ou updateOverrides() échoue donc en production réelle aujourd'hui, jamais détecté faute de
test exerçant ces quatre chemins avant cette mission.
Décision explicite validée avec l'architecte avant implémentation (hors périmètre strict
plan_id, mais bloquant pour livrer les tests changePlan()/grantPlan() requis par cette
mission, et sans lui ces méthodes ne fonctionnent tout simplement pas) : correctif minimal —
retrait de la clé stripe_invoice_id de l'insertion dans log(), et de la colonne correspondante
dans le SELECT de getHistory() (même cause, même défaut, découvert au même endroit). Le
paramètre $stripeInvoiceId est conservé dans les signatures (aucune modification des appelants)
mais n'est plus persisté.
11. Dette découverte, non traitée — subscription_plans diverge très au-delà de plan_id
En construisant des fixtures de test réalistes pour changePlan()/le dashboard admin,
subscription_plans local s'est révélé manquer 27 des 35 colonnes réelles (dont active et
ordre, utilisées par AdminDashboardController pour la requête « subs by plan »), avec en plus
un écart de type sur prix_mensuel (numeric(8,2) local vs integer/centimes réel). Décision
explicite validée avec l'architecte : ne pas élargir cette mission à l'alignement complet de
subscription_plans (chantier de la taille de DB-001 à part entière). Le test « subs by plan »
correspondant a été retiré (documenté en commentaire dans AdminTest.php) plutôt que contourné
silencieusement — aucun bug de production (les colonnes existent réellement en production ; le
problème est uniquement un écart de fidélité du schéma de test, révélé ici mais non corrigé).
Dette tracée pour un chantier futur séparé, non nommé/priorisé par cette mission.
12. Tests
620/620 (contre 25 avant cette mission dans MonetizationTest seul, elle-même déjà comptée dans
le 612/612 post-MON-SEC-001A) :
- MonetizationTest (+3) :
actor_subscriptions_plan_id_est_un_uuid_correspondant_a_subscription_ plans_id,subscription_logs_plan_id_est_egalement_un_uuid_apres_une_transition,actor_subscription_plan_relation_resout_le_bon_plan_via_uuid(relation directe + inverse). Toutes les fixtures existantes (createSubscription,assertDatabaseHas,assertJsonPath) migrées vers des UUID résolus dynamiquement (plus aucun littéral'free'/'starter'dans une colonneplan_id). - AdminTest (+3) :
patch_admin_subscriptions_id_plan_stocke_luuid_du_plan,post_admin_subscriptions_id_grant_stocke_luuid_du_plan,get_admin_subscriptions_id_history_resout_plan_nom_via_uuid— nouveaux,changePlan()/grantPlan()n'avaient jamais été testés avant cette mission (cause du bug §10, révélé par ces tests mêmes). - SchemaAlignmentTest (+2) : type/FK exacts sur
actor_subscriptions.plan_idetsubscription_logs.plan_id(uuid, nullabilité, présence/absence de FK). - ActeurWriteTest, RewardEngineTest : fixtures adaptées (plan gratuit réellement créé et
résolu,
ensureFreeSubscription()exige désormais un plan réel).
Cycle complet MON-SEC-001A (free → paid → update → cancel → free) toujours vert, toujours une
seule ligne à chaque étape — aucune régression sur l'idempotence/transactionnalité déjà acquise.
13. Reconstruction base fraîche
Obligatoire, exécutée deux fois (validation intermédiaire des migrations seules, puis validation
finale complète) : base PostgreSQL jetable, 111 migrations (109 historiques + 2 nouvelles),
succès intégral. Schéma résultant vérifié colonne par colonne :
actor_subscriptions.plan_id = uuid NOT NULL + FK actor_subscriptions_plan_id_fkey →
subscription_plans(id) ; subscription_logs.plan_id = uuid nullable, sans FK ;
subscription_logs porte bien admin_note/admin_user_id/periode/montant_ht. Conforme en
tout point aux deux dumps de référence. Aucune migration historique cassée.
14. Dettes restantes
subscription_plansdiverge du schéma réel sur 27 colonnes + un type (prix_mensuel) — §11, chantier séparé non ouvert.AdminDashboardControllernon testé de bout en bout (bloqué par la dette n°1) — code déjà correct par inspection et par un test équivalent du même motif de jointure (§8).- MON-SEC-002 (ordre des événements Stripe, audit exhaustif par
event_id) — inchangé, toujours non traité (MON-SEC-001A). - MON-ARCH-001 (ownership
syncActorBadge()) — inchangé, toujours non traité. - Champs historiques/admin non nettoyés au retour
paid → free— inchangé (MON-SEC-001A §14), non concerné parplan_id.