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():INSERTdirect d'une nouvelle ligne pour un acteur qui en possède déjà une (la lignefreeposée à sa création) → violeraitUNIQUE(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'INSERTd'une seconde lignefree/active→SQLSTATE[23505](reproduit). En cas d'échec de l'INSERT, l'acteur reste sans aucun abonnement actif (la lignecanceledreste 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éesubscription_logscorrespondante (INSERT inconditionnel à chaque appel), même quand la mutation deactor_subscriptionselle-même ne créait pas de doublon. - Aucune frontière transactionnelle : mutation de
actor_subscriptionset é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 :
- Lit la ligne existante de l'acteur (au plus une, par construction).
- Résout
plan_id: valeur fournie, ou valeur existante sinull(casonSubscriptionUpdated, 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). - 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). - 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 anciensexists()puisinsert()), puis écriture d'une lignesubscription_logs, le tout dansDB::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
| Transition | Avant | Après |
|---|---|---|
| Création acteur → free | exists() 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 inconditionnel | upsertActorSubscription() 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 reproduite | upsertActorSubscription() 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 danssubscribe(), jamais à l'intérieur) ; syncActorBadge()— lit l'état fraîchement committé deactor_subscriptionset écrit suracteurs, 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_subscriptionspar 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
createdtardif, reçu après qu'undeletedpour le même abonnement a déjà été traité, réactiverait la ligne (paid) après un retour àfreedéjà appliqué ; la convergence d'état protège contre le replay, pas contre le désordre ; - un audit exhaustif par
event_idStripe — aucun identifiant d'événement (evt_xxx) n'est capturé nulle part (vérifié : absent d'actor_subscriptions, absent du schéma de test desubscription_logs; une colonnemetadata jsonbexiste dans le schéma réel déployé desubscription_logsmais 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 parevent_idStripe 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 desubscribe()(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_idcorrects, logsubscribecréé, aucune violationUNIQUE;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_free—COUNT(actor_subscriptions) = 1vé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épassantvarchar(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 : viaDB::listen()(API Laravel publique, aucun accès aux détails internes), force l'échec spécifiquement sur l'INSERTdanssubscription_logs, après que la mutationactor_subscriptionsa 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é deDB::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_startcurrent_period_endstarted_atpromo_end_atadmin_notegranted_byfeature_overridesfeature_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.