Quand l'user annule via le Customer Portal Stripe, la subscription reste
`active` jusqu'à la fin du cycle (cancel_at_period_end=true) — Stripe
n'envoie le `subscription.deleted` qu'à period_end. Avant ce commit, l'UI
affichait toujours "prochaine facture le 28 mai" comme avant l'annulation,
ce qui faisait croire à l'user qu'il allait re-payer.
Backend :
- Migration `cancel_at_period_end boolean DEFAULT false` sur orgs.
- `applySubscriptionToOrg` : lit le flag du Stripe Subscription et
persiste sur l'org.
- `handleSubscriptionDeleted` : reset le flag à false (cohérence DB).
- `OrgSubscriptionState` : nouveau champ `cancelAtPeriodEnd: boolean`.
- Endpoint `POST /api/v1/billing/reactivate` :
• Idempotent (si déjà actif → no-op + 200)
• Appelle `subscriptions.update(id, { cancel_at_period_end: false })`
• Persist le nouvel état sur l'org
Frontend :
- Hook `useReactivateSubscription` (mutation + invalidate billing query).
- `CurrentPlanStrip` :
• Détecte `isCancelling = plan !== 'free' && cancelAtPeriodEnd`
• Switch border/bg en mode rubis-deep + rubis-glow pour attirer l'œil
• Icône Clock à la place de Gem (visuel "compte à rebours")
• Badge "ANNULÉ" en uppercase
• Sous-titre : "Accès Pro jusqu'au DD/MM, puis retour automatique
au plan Free."
• Bouton primary "Réactiver" (RotateCcw icon) qui remplace "Gérer"
• Masque la progress bar Free (non pertinente)
- `SubscriptionState` type étendu avec `cancelAtPeriodEnd`.
- Test factory updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
370 lines
13 KiB
TypeScript
370 lines
13 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()
|
|
const updated = await stripe.subscriptions.update(org.stripeSubscriptionId, {
|
|
cancel_at_period_end: false,
|
|
})
|
|
org.cancelAtPeriodEnd = !!updated.cancel_at_period_end // = 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
|
|
// 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
|
|
await org.save()
|
|
|
|
logger.info(
|
|
{
|
|
orgId,
|
|
plan,
|
|
cycle,
|
|
status: subscription.status,
|
|
subscriptionId: subscription.id,
|
|
},
|
|
'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
|
|
}
|
|
}
|