From 031b8cc0621630bd838eba9bc210287819c052f0 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 17:18:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(billing):=20d=C3=A9tecte=20aussi=20cancel?= =?UTF-8?q?=5Fat=20(Customer=20Portal)=20+=20reactivate=20sans=20conflit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug : le Stripe Customer Portal n'utilise pas `cancel_at_period_end:true` mais `cancel_at:` pour scheduler l'annulation. Notre webhook ne lisait que le booléen → l'annulation via portail n'était pas remontée côté DB, l'UI ne montrait jamais le bandeau "annulé". Webhook handler : - Détecte l'annulation via EITHER `cancel_at_period_end` OR `cancel_at` et unifie en un seul booléen `cancelAtPeriodEnd` côté org. Endpoint /reactivate : - Stripe REFUSE qu'on passe `cancel_at_period_end:false` ET `cancel_at:null` dans le même update ("Please pass in only one"). On retrieve d'abord la sub pour savoir laquelle des 2 mécaniques est active, puis on clear uniquement celle-là. Logs enrichis : `cancelAtPeriodEnd` et `cancelAt` désormais loggés à chaque `applySubscriptionToOrg` pour que le diagnostic soit immédiat. Co-Authored-By: Claude Opus 4.7 --- .../api/app/controllers/billing_controller.ts | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/api/app/controllers/billing_controller.ts b/apps/api/app/controllers/billing_controller.ts index bf20c92..730af1d 100644 --- a/apps/api/app/controllers/billing_controller.ts +++ b/apps/api/app/controllers/billing_controller.ts @@ -126,10 +126,24 @@ export default class BillingController { } const stripe = getStripe() - const updated = await stripe.subscriptions.update(org.stripeSubscriptionId, { - cancel_at_period_end: false, - }) - org.cancelAtPeriodEnd = !!updated.cancel_at_period_end // = false normalement + // Stripe expose 2 mécaniques d'annulation et REFUSE qu'on passe les 2 + // dans le même update : + // - `cancel_at_period_end: true` (booléen) — API directe / CLI + // - `cancel_at: ` — Customer Portal + // + // On retrieve le sub d'abord pour savoir laquelle est posée, puis on + // clear uniquement celle-là. + const current = await stripe.subscriptions.retrieve(org.stripeSubscriptionId) + const updatePayload: Stripe.SubscriptionUpdateParams = current.cancel_at + ? { cancel_at: null } + : { cancel_at_period_end: false } + + const updated = await stripe.subscriptions.update( + org.stripeSubscriptionId, + updatePayload + ) + org.cancelAtPeriodEnd = + !!updated.cancel_at_period_end || !!updated.cancel_at // = false normalement org.subscriptionStatus = updated.status await org.save() @@ -336,9 +350,17 @@ export default class BillingController { org.currentPeriodEnd = item.current_period_end ? DateTime.fromSeconds(item.current_period_end) : null - // L'user a-t-il programmé une annulation ? (via Customer Portal) - // Reflété en UI pour qu'il sache que son accès s'éteint au period_end. - org.cancelAtPeriodEnd = !!subscription.cancel_at_period_end + + // Détection de l'annulation programmée. Stripe expose DEUX mécaniques : + // - `cancel_at_period_end: true` (booléen) → utilisé par l'API directe + // (`stripe.subscriptions.update --cancel-at-period-end=true`) + // - `cancel_at: ` (epoch) → utilisé par le Customer Portal + // qui schedule un cancel à une date précise (généralement = period_end). + // + // Sémantiquement c'est la même chose : "le sub s'éteindra à cette date". + // On unifie en un seul booléen pour le reste de l'app. + org.cancelAtPeriodEnd = + !!subscription.cancel_at_period_end || !!subscription.cancel_at await org.save() logger.info( @@ -348,6 +370,8 @@ export default class BillingController { cycle, status: subscription.status, subscriptionId: subscription.id, + cancelAtPeriodEnd: !!subscription.cancel_at_period_end, + cancelAt: subscription.cancel_at, }, 'Subscription appliquée à l\'org' )