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 { 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/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 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 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 } }