import vine from '@vinejs/vine' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import logger from '@adonisjs/core/services/logger' import Organization from '#models/organization' import { getOrgSubscriptionState } from '#services/billing' import { getStripe } from '#services/stripe' import { createCheckoutSession, createTrialCheckoutSession, ensureStripeCustomer, handleCheckoutCompleted, handlePaymentFailed, handleSubscriptionDeleted, handleSubscriptionUpdate, handleTrialWillEnd, TrialAlreadyConsumedError, } from '#services/stripe_billing' 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']), }) ) 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/start-trial — auth. * * Démarrer l'essai 14 j Pro avec CB à l'inscription. Crée le Stripe * Customer si pas déjà, puis une Checkout Session avec * `trial_period_days`. Renvoie l'URL hostée Stripe vers laquelle le * SPA redirige. * * 409 si l'org a déjà consommé son essai (idempotence garde-fou). * * Body: { plan: 'pro'|'business' = 'pro', cycle: 'monthly'|'yearly' = 'monthly' } */ async startTrial({ auth, request, response }: HttpContext) { const organizationId = requireOrgId(auth) const user = auth.getUserOrFail() // Body optionnel : valeurs par défaut Pro mensuel (cas le plus // probable depuis le tunnel onboarding). const body = request.body() as { plan?: string; cycle?: string } const plan = body.plan === 'business' ? 'business' : 'pro' const cycle = body.cycle === 'yearly' ? 'yearly' : 'monthly' const org = await Organization.findOrFail(organizationId) const customerId = await ensureStripeCustomer(org, user) try { const result = await createTrialCheckoutSession({ org, customerId, plan, cycle, }) return response.json({ data: { url: result.url } }) } catch (err) { if (err instanceof TrialAlreadyConsumedError) { throw new Exception('Essai déjà consommé', { status: 409, code: 'trial_already_consumed', }) } throw err } } /** * POST /api/v1/billing/checkout — auth. * Crée une session Stripe Checkout standard (sans essai). Pour les * users post-trial qui upgrade, ou Free direct payant. * * 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 result = await createCheckoutSession({ org, customerId, plan, cycle }) return response.json({ data: { url: result.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: ` — 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 vers les handlers du service * `stripe_billing` (pour la testabilité). * * - checkout.session.completed → 1er paiement OK / trial démarré * - customer.subscription.{created,updated} → renouvellement, plan change, trial→active * - customer.subscription.deleted → annulation effective → free * - customer.subscription.trial_will_end → email recap J-3 avant trial_end * - invoice.payment_failed → past_due * * 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' }) } 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 { await dispatchWebhookEvent(event) } 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 }) } } /** * Dispatcher webhook → handler typé. Export séparé pour les tests qui * peuvent construire un `Stripe.Event` factice et vérifier que le bon * handler est appelé. */ export async function dispatchWebhookEvent(event: Stripe.Event): Promise { switch (event.type) { case 'checkout.session.completed': await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session) return case 'customer.subscription.created': case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object as Stripe.Subscription) return case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object as Stripe.Subscription) return case 'customer.subscription.trial_will_end': await handleTrialWillEnd(event.data.object as Stripe.Subscription) return case 'invoice.payment_failed': await handlePaymentFailed(event.data.object as Stripe.Invoice) return default: // On ignore les autres events. Stripe en envoie beaucoup, on n'en // a besoin que d'une poignée. return } }