fix(billing): détecte aussi cancel_at (Customer Portal) + reactivate sans conflit
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s

Bug : le Stripe Customer Portal n'utilise pas `cancel_at_period_end:true`
mais `cancel_at:<timestamp>` 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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 17:18:18 +02:00
parent cb87bbc8d1
commit 031b8cc062

View File

@ -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: <timestamp>` — 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: <timestamp>` (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'
)