Aller au contenu principal

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éelle actor_subscriptions_plan_id_fkey vers subscription_plans(id).
  • subscription_logs.plan_id : uuid nullable, 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 public GET /acteurs/{id}/subscription (ActorSubscriptionDTO) expose plan_id mais 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 .../plan et POST .../grant, endpoints déjà validés côté serveur en required|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() (lookup subscription_plans.slug = 'free'.id, lève \RuntimeException si 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->id au 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 aucun planId n'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 persistance plan_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 codewhere('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.phpactor_subscriptions.plan_id : varchar(50)uuid NOT NULL + FK vers subscription_plans(id). subscription_logs.plan_id : varchar(50)uuid nullable, sans FK (fidèle aux deux dumps).
  • 2026_08_17_000004_add_missing_columns_to_subscription_logs_table.php — ajoute admin_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 colonne plan_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_id et subscription_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_fkeysubscription_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

  1. subscription_plans diverge du schéma réel sur 27 colonnes + un type (prix_mensuel) — §11, chantier séparé non ouvert.
  2. AdminDashboardController non 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).
  3. MON-SEC-002 (ordre des événements Stripe, audit exhaustif par event_id) — inchangé, toujours non traité (MON-SEC-001A).
  4. MON-ARCH-001 (ownership syncActorBadge()) — inchangé, toujours non traité.
  5. Champs historiques/admin non nettoyés au retour paid → free — inchangé (MON-SEC-001A §14), non concerné par plan_id.