rubis/apps/api/app/controllers/billing_controller.ts
ordinarthur 031b8cc062
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
fix(billing): détecte aussi cancel_at (Customer Portal) + reactivate sans conflit
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>
2026-05-07 17:18:18 +02:00

394 lines
14 KiB
TypeScript

import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import Organization from '#models/organization'
import User from '#models/user'
import { getOrgSubscriptionState } from '#services/billing'
import { getStripe, STRIPE_LOOKUP_KEYS, getPriceByLookup } from '#services/stripe'
import env from '#start/env'
import type Stripe from 'stripe'
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
const checkoutValidator = vine.compile(
vine.object({
plan: vine.enum(['pro', 'business']),
cycle: vine.enum(['monthly', 'yearly']),
})
)
function lookupKeyFor(plan: 'pro' | 'business', cycle: 'monthly' | 'yearly') {
if (plan === 'pro') {
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.pro_monthly : STRIPE_LOOKUP_KEYS.pro_yearly
}
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.business_monthly : STRIPE_LOOKUP_KEYS.business_yearly
}
/**
* Crée ou retrouve le Stripe Customer associé à une org. On stocke
* `stripeCustomerId` sur l'org dès la 1re fois pour éviter les doublons.
*/
async function ensureStripeCustomer(org: Organization, user: User): Promise<string> {
if (org.stripeCustomerId) return org.stripeCustomerId
const stripe = getStripe()
const customer = await stripe.customers.create({
email: user.email,
name: org.name || user.fullName || user.email,
metadata: {
organization_id: org.id,
user_id: user.id,
},
})
org.stripeCustomerId = customer.id
await org.save()
return customer.id
}
export default class BillingController {
/**
* GET /api/v1/billing/subscription — auth.
* Retourne le plan courant + caps + état de souscription pour l'UI.
*/
async subscription({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const state = await getOrgSubscriptionState(organizationId)
return response.json({ data: state })
}
/**
* POST /api/v1/billing/checkout — auth.
* Crée une session Stripe Checkout et renvoie l'URL hostée — le SPA
* redirige vers Stripe pour que l'user paye en sécurité.
*
* Body: { plan: 'pro'|'business', cycle: 'monthly'|'yearly' }
*/
async checkout({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const user = auth.getUserOrFail()
const { plan, cycle } = await request.validateUsing(checkoutValidator)
const org = await Organization.findOrFail(organizationId)
const customerId = await ensureStripeCustomer(org, user)
const price = await getPriceByLookup(lookupKeyFor(plan, cycle))
const stripe = getStripe()
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${webUrl}/parametres/abonnement?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${webUrl}/parametres/abonnement?checkout=cancel`,
// On stocke org_id en metadata pour pouvoir lier côté webhook
// sans avoir besoin de regarder le customer.
subscription_data: {
metadata: { organization_id: organizationId, plan },
},
metadata: { organization_id: organizationId, plan },
allow_promotion_codes: true,
billing_address_collection: 'auto',
locale: 'fr',
})
return response.json({ data: { url: session.url } })
}
/**
* POST /api/v1/billing/reactivate — auth.
*
* Annule l'annulation programmée au period_end : set Stripe
* `cancel_at_period_end: false` et persiste côté org. Pas de proration,
* pas de paiement immédiat — la subscription continue son cycle normal.
*/
async reactivate({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const org = await Organization.findOrFail(organizationId)
if (!org.stripeSubscriptionId) {
throw new Exception('Aucune souscription active à réactiver', {
status: 400,
code: 'no_active_subscription',
})
}
if (!org.cancelAtPeriodEnd) {
// Idempotent : déjà actif, on renvoie OK sans toucher Stripe.
return response.json({ data: { ok: true } })
}
const stripe = getStripe()
// 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()
logger.info(
{ orgId: org.id, subscriptionId: org.stripeSubscriptionId },
'Subscription réactivée (cancel_at_period_end=false)'
)
return response.json({ data: { ok: true } })
}
/**
* POST /api/v1/billing/portal — auth.
* Crée une session Stripe Customer Portal pour gérer abonnement, CB,
* factures Stripe, annulation. UI hosted.
*/
async portal({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const org = await Organization.findOrFail(organizationId)
if (!org.stripeCustomerId) {
throw new Exception(
'Aucun customer Stripe pour cette organisation — passez d\'abord par checkout',
{ status: 400, code: 'no_stripe_customer' }
)
}
const stripe = getStripe()
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const session = await stripe.billingPortal.sessions.create({
customer: org.stripeCustomerId,
return_url: `${webUrl}/parametres/abonnement`,
locale: 'fr',
})
return response.json({ data: { url: session.url } })
}
/**
* POST /api/v1/billing/webhook — public (auth via signature Stripe).
*
* Stripe envoie les events de subscription ici. On vérifie la signature
* via le webhook secret puis on dispatch :
*
* - checkout.session.completed → premier paiement OK, set plan
* - customer.subscription.updated → renouvellement, plan change
* - customer.subscription.deleted → annulation effective → free
* - invoice.payment_failed → past_due (UI rappelle l'user)
*
* Idempotent : on traite chaque event en read-then-write sans assumer
* qu'il arrive une seule fois (Stripe peut re-livrer).
*/
async webhook({ request, response }: HttpContext) {
const stripe = getStripe()
const webhookSecret = env.get('STRIPE_WEBHOOK_SECRET')
if (!webhookSecret) {
throw new Exception('STRIPE_WEBHOOK_SECRET manquant', {
status: 500,
code: 'webhook_secret_missing',
})
}
const sig = request.header('stripe-signature')
if (!sig) {
throw new Exception('Signature Stripe manquante', { status: 400, code: 'missing_signature' })
}
// Adonis a déjà parsé le body en JSON : Stripe a besoin du raw, on le
// récupère via `request.raw()`.
const raw = request.raw()
if (!raw) {
throw new Exception('Raw body indisponible', {
status: 400,
code: 'no_raw_body',
})
}
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(raw, sig, webhookSecret)
} catch (err) {
logger.warn({ err }, 'Stripe webhook : signature invalide')
throw new Exception('Signature invalide', {
status: 400,
code: 'invalid_signature',
})
}
logger.info({ type: event.type, id: event.id }, 'Stripe webhook reçu')
try {
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
await this.handleSubscriptionUpdate(event.data.object as Stripe.Subscription)
break
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
break
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice)
break
default:
// On ignore les autres events. Stripe en envoie beaucoup, on
// n'en a besoin que d'une poignée.
break
}
} catch (err) {
// En cas d'erreur de traitement, on log mais on renvoie 200 quand
// même : Stripe va retry plein de fois sinon. Mieux vaut perdre un
// event qu'avoir 50 doublons. Pour les events critiques on a déjà
// l'idempotence (lookup par customer/subscription id).
logger.error({ err, type: event.type, id: event.id }, 'Stripe webhook : erreur de traitement')
}
return response.json({ received: true })
}
// -----------------------------------------------------------------------
// Handlers webhook
// -----------------------------------------------------------------------
private async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const orgId = session.metadata?.['organization_id']
if (!orgId) {
logger.warn({ session: session.id }, 'checkout.completed sans organization_id en metadata')
return
}
if (!session.subscription || typeof session.subscription !== 'string') return
const stripe = getStripe()
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
expand: ['items.data.price'],
})
await this.applySubscriptionToOrg(orgId, subscription)
}
private async handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const orgId = subscription.metadata?.['organization_id']
if (!orgId) {
// Fallback : remonter via stripeCustomerId
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) {
logger.warn(
{ subscriptionId: subscription.id, customerId },
'subscription.updated : org introuvable'
)
return
}
await this.applySubscriptionToOrg(org.id, subscription)
return
}
await this.applySubscriptionToOrg(orgId, subscription)
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) return
org.plan = 'free'
org.stripeSubscriptionId = null
org.subscriptionStatus = 'canceled'
org.billingCycle = null
org.currentPeriodEnd = null
org.cancelAtPeriodEnd = false
await org.save()
logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)')
}
private async handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!customerId) return
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) return
org.subscriptionStatus = 'past_due'
await org.save()
logger.warn({ orgId: org.id, invoiceId: invoice.id }, 'Paiement échoué — org marquée past_due')
}
/**
* Applique l'état d'une Stripe Subscription à une org : plan, cycle,
* status, period_end. Idempotent.
*/
private async applySubscriptionToOrg(orgId: string, subscription: Stripe.Subscription) {
const org = await Organization.find(orgId)
if (!org) {
logger.warn({ orgId }, 'applySubscriptionToOrg : org introuvable')
return
}
const item = subscription.items.data[0]
if (!item) return
const price = item.price as Stripe.Price
const lookupKey = price.lookup_key as string | null
const plan = this.planFromLookupKey(lookupKey)
const cycle = this.cycleFromLookupKey(lookupKey)
org.plan = plan
org.stripeSubscriptionId = subscription.id
org.subscriptionStatus = subscription.status
org.billingCycle = cycle
org.currentPeriodEnd = item.current_period_end
? DateTime.fromSeconds(item.current_period_end)
: null
// 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(
{
orgId,
plan,
cycle,
status: subscription.status,
subscriptionId: subscription.id,
cancelAtPeriodEnd: !!subscription.cancel_at_period_end,
cancelAt: subscription.cancel_at,
},
'Subscription appliquée à l\'org'
)
}
private planFromLookupKey(key: string | null): 'free' | 'pro' | 'business' {
if (!key) return 'free'
if (key.includes('business')) return 'business'
if (key.includes('pro')) return 'pro'
return 'free'
}
private cycleFromLookupKey(key: string | null): 'monthly' | 'yearly' | null {
if (!key) return null
if (key.endsWith('_yearly')) return 'yearly'
if (key.endsWith('_monthly')) return 'monthly'
return null
}
}