Aller au contenu principal

MON-SEC-001A — Fiabilisation du cycle d'abonnement Actor

Statut

Implémenté et revu (revue pré-finalisation ciblée sur l'idempotence et la couverture de subscribe()) — en cours de finalisation (commit/PR/merge).

Périmètre

Corrige les défauts structurels du cycle free → paid → update → cancel → free dans app/Modules/Monetization/Services/MonetizationWriteService.php, sans modifier la représentation de actor_subscriptions.plan_id (varchar(50)/slug conservé — la cible UUID appartient à MON-SEC-001B). Arbitrages MON-SEC-001 déjà figés, non rouverts ici : modèle snapshot (une ligne actor_subscriptions par acteur, UNIQUE(acteur_id) conservée), historique porté par subscription_logs.

1. Défauts avant MON-SEC-001A

Démontrés par l'audit MON-SEC-001 et reproduits par exécution réelle du pipeline :

  • subscribe() : INSERT direct d'une nouvelle ligne pour un acteur qui en possède déjà une (la ligne free posée à sa création) → violerait UNIQUE(acteur_id) dès le premier abonnement payant réel.
  • onSubscriptionDeleted()autoSubscribeFree() : UPDATE status='canceled' sur la ligne existante (committé seul, hors transaction), puis tentative d'INSERT d'une seconde ligne free/activeSQLSTATE[23505] (reproduit). En cas d'échec de l'INSERT, l'acteur reste sans aucun abonnement actif (la ligne canceled reste seule, committée indépendamment).
  • Aucun des trois handlers webhook (created/updated/deleted) n'était idempotent au sens strict : un replay dupliquait systématiquement l'entrée subscription_logs correspondante (INSERT inconditionnel à chaque appel), même quand la mutation de actor_subscriptions elle-même ne créait pas de doublon.
  • Aucune frontière transactionnelle : mutation de actor_subscriptions et écriture du log associé étaient deux opérations indépendantes, chacune committée séparément.

2. Invariant snapshot (rappel, non rouvert)

actor_subscriptions représente l'abonnement courant de l'acteur — au plus une ligne par acteur, garantie par actor_subscriptions_acteur_id_key = UNIQUE(acteur_id) (schéma déployé, stable depuis 2026-04-10). L'historique appartient exclusivement à subscription_logs, jamais à plusieurs lignes actor_subscriptions.

3. Primitif retenu

upsertActorSubscription() — méthode privée de MonetizationWriteService (pas de nouveau contrat inter-contextes : ni SubscriptionManager, ni classe séparée — l'audit n'a démontré le besoin de centraliser que les écritures internes à ce service, jamais un besoin d'exposition externe).

private function upsertActorSubscription(
string $acteurId,
?string $planId, // null = ne modifie pas le plan_id existant
string $status,
?string $stripeSubscriptionId,
string $logAction,
): string

Comportement :

  1. Lit la ligne existante de l'acteur (au plus une, par construction).
  2. Résout plan_id : valeur fournie, ou valeur existante si null (cas onSubscriptionUpdated, qui ne doit jamais réécrire le plan), ou 'free' en tout dernier recours si aucune ligne n'existe et qu'aucun plan n'est fourni (cas anormal, non rencontré par les appelants actuels).
  3. Si l'état demandé (plan_id, status, stripe_subscription_id) est strictement identique à l'état existant : aucune écriture, retour immédiat. C'est le mécanisme d'idempotence (§6).
  4. Sinon : INSERT ... ON CONFLICT (acteur_id) DO UPDATE SET plan_id, status, stripe_subscription_id, updated_at (atomique, une seule instruction SQL — élimine la fenêtre TOCTOU des anciens exists() puis insert()), puis écriture d'une ligne subscription_logs, le tout dans DB::transaction() (§5).

N'accepte que ce que l'audit a démontré nécessaire (acteur, plan, statut, id Stripe, action de log) — pas de champs de période, pas de paramètres spéculatifs : aucun des appelants actuels n'en a besoin, et en ajouter sans besoin démontré aurait été construire un framework générique (explicitement proscrit par la mission).

Non branché sur AdminSubscriptionWriteService : changePlan()/grantPlan() ne font jamais qu'un UPDATE sur une ligne déjà identifiée par son id de souscription (jamais d'INSERT) — ils ne violent pas UNIQUE(acteur_id) et ne sont pas concernés par le défaut corrigé ici. Les brancher sur le même primitif aurait exigé de lui ajouter des paramètres (admin_note, granted_by, périodes, note) sans bénéfice de fiabilité démontré — écarté conformément à la mission (« si l'admin peut rester fonctionnel sans changement, préfère ne pas le toucher »).

4. Cycle avant/après

TransitionAvantAprès
Création acteur → freeexists() puis insert() (TOCTOU théorique)INSERT ... ON CONFLICT (acteur_id) DO NOTHING (atomique)
free → paid (subscribe())INSERT direct → violerait UNIQUE(acteur_id)upsertActorSubscription() → converge sur la ligne existante
pending → active (created)UPDATE par stripe_subscription_id + INSERT de secours si absente (deux requêtes, non atomique)upsertActorSubscription() par acteur_id (une opération atomique, couvre les deux cas)
paid → paid (updated)UPDATE par stripe_subscription_id seul, log inconditionnelupsertActorSubscription() par acteur_id, planId: null (plan jamais touché), log seulement si le statut change réellement
paid → canceled → free (deleted)UPDATE canceled (committé seul) puis INSERT free séparé → violation UNIQUE reproduiteupsertActorSubscription() unique, directement vers plan=free/status=active — plus d'état intermédiaire persistant

ensureFreeSubscription() conserve exactement son comportement fonctionnel (ENG-001.3 / PR-002) : acteur sans ligne → free/active ; second appel ou ligne déjà existante (quel que soit son état) → aucune modification.

5. Transactions

Frontière retenue : mutation actor_subscriptions + écriture subscription_logs, dans DB::transaction() — commitent ensemble ou pas du tout. Hors transaction, sans exception :

  • tous les appels Stripe (Subscription::cancel/create, déjà positionnés avant l'appel au primitif dans subscribe(), jamais à l'intérieur) ;
  • syncActorBadge() — lit l'état fraîchement committé de actor_subscriptions et écrit sur acteurs, une table distincte. Conservé hors transaction et non modifié, comme demandé (voir dette §12).

Conforme à ADR-014 principe 6 (« une transaction métier ne couvre qu'un seul Bounded Context ») et au précédent déjà établi ailleurs dans le module (BoostPurchaseService::confirmPurchase(), BoostUsageService), qui suit le même patron (écritures DB transactionnelles, appels réseau externes toujours en dehors).

6. Idempotence

Arbitrage explicite (mission §8/§9 : « si une vraie déduplication nécessite une nouvelle colonne/table ou une migration, STOP avant migration ») : aucune migration n'a été nécessaire.

Choix retenu : idempotence par convergence d'état plutôt que par déduplication d'un identifiant d'événement Stripe (evt_xxx). Le primitif compare l'état demandé à l'état persisté existant (plan_id, status, stripe_subscription_id) ; si strictement identique, aucune écriture n'a lieu. Cela suffit à garantir :

  • un replay exact d'un même événement Stripe est un no-op complet (ligne et log) ;
  • tout appel qui ne change rien d'observable est un no-op, qu'il s'agisse d'un replay ou d'un nouvel événement dont l'effet net est nul.

Écarté : une table/colonne de déduplication par evt_xxx. Aurait exigé une migration (nouvelle colonne ou table), alors que le patron déjà en place ailleurs dans le même module (BoostPurchaseService::confirmPurchase()) prouve qu'une clé métier existante (ici, acteur_id + l'état cible) suffit à garantir l'idempotence sans nouvelle structure — cohérent avec ADR-014 invariant 8 (« tout consommateur d'événement est idempotent ») sans imposer de mécanisme technique particulier.

Limite assumée : deux événements distincts qui produiraient, par coïncidence, exactement le même état cible que la ligne actuelle seraient collapsés en un seul no-op (le second n'ajoute pas de log). Jugé acceptable — un événement qui ne change rien d'observable au snapshot courant n'a pas besoin d'entrée d'audit séparée ; l'historique Stripe reste consultable côté Stripe indépendamment de subscription_logs.

Niveau exact garanti

Garanti :

  • replay strict de created, updated, deleted (payload identique rejoué) → aucune mutation supplémentaire, aucun doublon de log, état final identique ;
  • au plus une ligne actor_subscriptions par acteur, en toute circonstance ;
  • aucun doublon de log lorsque l'état (plan_id/status/stripe_subscription_id) est inchangé ;
  • convergence vers le même état final, quel que soit le nombre de fois où un événement identique est traité.

Non garanti dans MON-SEC-001A (dette explicite, tracée séparément — voir ci-dessous, non traitée ici) :

  • l'ordre de livraison des événements Stripe — un événement created tardif, reçu après qu'un deleted pour le même abonnement a déjà été traité, réactiverait la ligne (paid) après un retour à free déjà appliqué ; la convergence d'état protège contre le replay, pas contre le désordre ;
  • un audit exhaustif par event_id Stripe — aucun identifiant d'événement (evt_xxx) n'est capturé nulle part (vérifié : absent d'actor_subscriptions, absent du schéma de test de subscription_logs ; une colonne metadata jsonb existe dans le schéma réel déployé de subscription_logs mais pas dans le schéma de test local — l'utiliser exigerait une migration d'alignement de schéma, hors périmètre de MON-SEC-001A) ;
  • la distinction de deux événements Stripe différents produisant le même état — le second devient invisible dans subscription_logs (voir « limite assumée » ci-dessus).

Dette tracée séparément, non implémentée ici :

MON-SEC-002 — Stripe event ordering & durable event idempotency

Couvrirait : garantie d'ordre (ou détection explicite de désordre) pour les événements customer.subscription.*, et une déduplication durable par event_id Stripe si un besoin produit d'audit exhaustif est démontré. Nécessiterait a minima une migration (colonne ou table de suivi d'événement) — à arbitrer explicitement avant tout code, conformément à IA-GOVERNANCE.md.

7. Replay

Testé explicitement pour les trois événements (created, updated, deleted) envoyés deux fois avec un payload strictement identique : dans les trois cas, une seule ligne actor_subscriptions, aucun doublon de log, état final identique au premier appel, aucune exception.

8. Logs

subscription_logs n'est pas modifiée dans son schéma (colonnes ou type de plan_id — appartient à MON-SEC-001B). Le comportement change : le log reflète désormais la valeur de plan_id résultant de l'opération (cohérent sur tous les appelants, y compris onSubscriptionDeleted, qui journalisait auparavant l'ancien plan payant sous l'action canceled — désormais le plan gratuit restauré, sous la même action canceled). Aucun test préexistant ne dépendait de l'ancienne valeur (le seul test qui exerçait ce chemin était le test précédemment marqué skipped, réécrit dans cette mission). Le log n'est plus écrit à chaque appel mais seulement lorsque l'état change réellement (§6).

9. Comportement Stripe

Inchangé, non modifié dans cette mission : MonetizationController::webhook() valide la signature, dispatch ProcessStripeWebhook en queue, répond HTTP 200 immédiatement — avant tout traitement métier. ProcessStripeWebhook (3 tentatives, backoff [60,300,900]s) n'a pas été modifié : sa capacité à « converger après retry » découle désormais entièrement de l'idempotence du service (§6), pas d'un changement du job lui-même. Un événement dont le traitement échoue pour une raison durable (ex. donnée malformée) continue d'épuiser ses 3 tentatives puis d'atterrir dans failed_jobs, inchangé.

10. Admin

AdminSubscriptionWriteService non modifié (voir §3, dernier paragraphe) — reste fonctionnel sans changement, comme permis par la mission.

11. Tests

25 tests dans MonetizationTest (contre 16 + 1 skip avant cette mission) :

  • le test précédemment skipped (résiliation) réécrit sans skip, avec assertions mises à jour pour l'état cible (une ligne, free/active) ;
  • subscribe_converge_vers_la_ligne_free_existante_sans_seconde_ligne — test end-to-end réel de subscribe() (requête HTTP complète : ownership, SubscribeRequest, MonetizationController::subscribe(), MonetizationWriteService::subscribe()). Le SDK Stripe PHP expose un point d'extension officiel non exploité jusqu'ici, \Stripe\ApiRequestor::setHttpClient(), permettant d'injecter un faux client HTTP (tests/Support/FakeStripeSubscriptionHttpClient.php, nouveau, test-only) sans toucher au code de production. Vérifie : une seule ligne, plan_id/status/stripe_subscription_id corrects, log subscribe créé, aucune violation UNIQUE ;
  • webhook_subscription_created_converge_vers_ligne_free_existante_sans_doublon — couvre à la fois « souscription payante » et « webhook arrivé avant la finalisation du chemin HTTP » ;
  • cycle_complet_free_paid_updated_canceled_freeCOUNT(actor_subscriptions) = 1 vérifié à chaque étape du cycle complet ;
  • 3 tests de replay (created, updated, deleted ×2) — aucune ligne ni log dupliqués ;
  • echec_pendant_la_mutation_ne_laisse_pas_un_demi_etat — déclenche une vraie erreur SQL (valeur de statut dépassant varchar(50)) via un payload webhook réaliste, sans coupler le test à un détail d'implémentation interne ; prouve la direction « la mutation échoue → aucun log n'est ajouté » ;
  • echec_pendant_l_ecriture_du_log_annule_egalement_la_mutation — complète le test précédent en prouvant la direction inverse : via DB::listen() (API Laravel publique, aucun accès aux détails internes), force l'échec spécifiquement sur l'INSERT dans subscription_logs, après que la mutation actor_subscriptions a déjà été exécutée (non commitée) ; vérifie que cette mutation est bien annulée avec le log. Les deux tests ensemble prouvent l'atomicité de DB::transaction() dans les deux directions.

subscribe() est donc désormais testé de bout en bout — la limite précédemment documentée (absence de mock Stripe SDK) est résolue sans aucune modification du code de production.

12. Dette plan_id (reportée à MON-SEC-001B)

Intacte. plan_id reste un slug (varchar(50)) dans actor_subscriptions et subscription_logs. Aucun writer, aucun reader, aucune migration touchés sur ce point.

13. Dette badge (MON-ARCH-001, non ouverte)

syncActorBadge() reste inchangée et hors transaction. Ownership de acteurs.badge_verifie/ badge_label écrit depuis le Bounded Context Monetization : dette tracée sous MON-ARCH-001 — Découpler la synchronisation du badge Actor depuis Monetization, non ouverte, non traitée ici.

14. Dette champs historiques/admin non nettoyés

Confirmé par lecture de la SQL réellement générée par l'upsert (tracée via DB::listen()) : ON CONFLICT (acteur_id) DO UPDATE SET ne touche que plan_id, status, stripe_subscription_id, updated_at. Tous les autres champs restent inchangés lors d'une transition, y compris le retour paid → free :

  • current_period_start
  • current_period_end
  • started_at
  • promo_end_at
  • admin_note
  • granted_by
  • feature_overrides
  • feature_override_until

Sans conséquence observable pour un cycle 100 % Stripe (aucun writer de ce cycle ne les renseigne jamais). Mais si un acteur a préalablement reçu un grantPlan() admin (qui, lui, remplit current_period_start/current_period_end/admin_note/granted_by), puis souscrit/résilie ensuite via Stripe, ces valeurs resteraient périmées sur la ligne après le retour à free. Comportement préexistant, non introduit par MON-SEC-001A (l'ancien code ne les réinitialisait pas non plus). Non corrigé ici — à revoir lors de MON-SEC-001B ou séparément, selon la pertinence métier réelle (dépend notamment de l'arbitrage plan_id, qui touche les mêmes writers).

15. Arbitrages restant nécessaires

Aucun arbitrage architectural nouveau requis par cette mission — les deux arbitrages structurants (snapshot, primitif interne non exposé) étaient déjà tranchés en amont. Un point mineur, sans impact fonctionnel, à noter pour mémoire : le choix de journaliser désormais la valeur résultante de plan_id plutôt que l'ancienne valeur sur onSubscriptionDeleted (§8) est une clarification de comportement, pas un arbitrage bloquant — signalé ici pour transparence, pas pour validation avant poursuite.