/** * Orchestration billing Stripe — fonctions pures testables, sans * dépendance HTTP. Le controller (`apps/api/app/controllers/billing_controller.ts`) * ne fait plus que parser la requête et déléguer ici. * * Toutes les fonctions sont conçues pour être appelées avec un payload * Stripe pré-construit (Subscription, Checkout.Session, Invoice...) ce * qui permet aux tests d'injecter des objets factices sans hit réseau. * * cf. docs/tech/stripe-trial-with-card.md pour l'archi cible. */ import { DateTime } from 'luxon' import logger from '@adonisjs/core/services/logger' import type Stripe from 'stripe' import Organization from '#models/organization' import User from '#models/user' import env from '#start/env' import { TRIAL_PERIOD_DAYS, cycleFromLookupKey, getPriceByLookup, getStripe, lookupKeyFor, planFromLookupKey, } from '#services/stripe' // --------------------------------------------------------------------------- // Customer creation (idempotent) // --------------------------------------------------------------------------- /** * 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. * * Idempotent : appel répétés → retourne le même ID si déjà posé. */ export 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 } // --------------------------------------------------------------------------- // Checkout Session creation // --------------------------------------------------------------------------- export type CheckoutPlan = 'pro' | 'business' export type CheckoutCycle = 'monthly' | 'yearly' /** * Result type : URL hostée Stripe à laquelle rediriger l'user. */ export type CheckoutSessionResult = { url: string sessionId: string } /** * Crée une session Checkout standard (sans essai), pour les users qui * ont déjà eu leur trial ou qui passent du Free direct payant. * * Pré-condition : `org.stripeCustomerId` doit être posé. Appeler * `ensureStripeCustomer` avant si besoin. */ export async function createCheckoutSession(opts: { org: Organization customerId: string plan: CheckoutPlan cycle: CheckoutCycle }): Promise { const { org, customerId, plan, cycle } = opts 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`, subscription_data: { metadata: { organization_id: org.id, plan }, }, metadata: { organization_id: org.id, plan }, allow_promotion_codes: true, billing_address_collection: 'auto', locale: 'fr', }) if (!session.url) { throw new Error('Stripe a renvoyé une session sans URL') } return { url: session.url, sessionId: session.id } } /** * Crée une session Checkout en mode **essai 14 jours avec CB * collectée**. Stripe place la subscription en `status: 'trialing'` dès * la complétion de la session. À J+14, Stripe prélève automatiquement * et passe la subscription en `active` (ou `past_due` si CB refusée). * * Garde-fou anti-double-trial : si l'org a déjà un `stripeSubscriptionId` * ou un `trialEndsAt` posé (donc trial déjà consommé), on throw — le * caller doit rediriger vers `createCheckoutSession` standard. * * Pré-condition : `org.stripeCustomerId` doit être posé. */ export async function createTrialCheckoutSession(opts: { org: Organization customerId: string plan: CheckoutPlan cycle: CheckoutCycle }): Promise { const { org, customerId, plan, cycle } = opts if (org.trialEndsAt || org.stripeSubscriptionId) { throw new TrialAlreadyConsumedError() } 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 }], /** * Redirige sur `/onboarding/compte` après collecte CB — le tunnel * onboarding reprend la main. Le webhook `checkout.session.completed` * persiste le `trialEndsAt` en parallèle. */ success_url: `${webUrl}/onboarding/compte?trial=started&session_id={CHECKOUT_SESSION_ID}`, /** * Si l'user ferme Checkout sans valider, on le ramène sur l'écran * billing avec un toast d'erreur — il peut réessayer OU cliquer le * fallback Free. */ cancel_url: `${webUrl}/onboarding/billing?trial=cancel`, subscription_data: { trial_period_days: TRIAL_PERIOD_DAYS, metadata: { organization_id: org.id, plan }, }, metadata: { organization_id: org.id, plan, is_trial: 'true' }, /** * Important : on collecte la CB même pour un trial (sinon Stripe * ne pourra pas prélever à J+14). `payment_method_collection` * par défaut est 'always', on l'expose pour la lisibilité. */ payment_method_collection: 'always', allow_promotion_codes: true, billing_address_collection: 'auto', locale: 'fr', }) if (!session.url) { throw new Error('Stripe a renvoyé une session sans URL') } return { url: session.url, sessionId: session.id } } /** * Sentinel error pour signaler à l'API qu'un user tente de démarrer un * essai après en avoir déjà eu un. Le controller mappe ça en 409 * `trial_already_consumed`. */ export class TrialAlreadyConsumedError extends Error { constructor() { super('Essai déjà consommé pour cette organisation') this.name = 'TrialAlreadyConsumedError' } } // --------------------------------------------------------------------------- // Webhook handlers — fonctions pures testables // --------------------------------------------------------------------------- /** * Applique l'état d'une Stripe Subscription à une org : plan, cycle, * status, period_end, trial_end. Idempotent — appeler 2× avec le même * payload donne le même état final. * * **Pourquoi pas privé** : extrait du controller pour testabilité. * Les tests construisent des `Stripe.Subscription` partiels et vérifient * les colonnes DB après appel. */ export async function applySubscriptionToOrg( orgId: string, subscription: Stripe.Subscription ): Promise { 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 const plan = planFromLookupKey(lookupKey) const cycle = 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 /** * `trial_end` n'est posé par Stripe que pendant un trial. Une fois * passé en `active`, il reste populé (date dans le passé) — on le * conserve pour l'historique sans logique métier dessus. À la * cancellation `null` — on garde l'ancienne valeur côté org pour * pouvoir reconstituer "vous aviez commencé en essai". */ if (subscription.trial_end) { org.trialEndsAt = DateTime.fromSeconds(subscription.trial_end) } org.cancelAtPeriodEnd = !!subscription.cancel_at_period_end || !!subscription.cancel_at await org.save() logger.info( { orgId, plan, cycle, status: subscription.status, subscriptionId: subscription.id, trialEnd: subscription.trial_end, cancelAtPeriodEnd: !!subscription.cancel_at_period_end, }, 'Subscription appliquée à l\'org' ) } /** * Handler `checkout.session.completed`. Fetch la subscription créée * et l'applique à l'org. Lookup d'org via metadata.organization_id. */ export async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise { const orgId = session.metadata?.['organization_id'] if (!orgId) { logger.warn( { sessionId: 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 applySubscriptionToOrg(orgId, subscription) } /** * Handler `customer.subscription.{created,updated}`. Idempotent. * * Cherche l'org via `metadata.organization_id` puis via * `stripeCustomerId` en fallback (cas des subscriptions modifiées * côté Customer Portal qui ne propagent pas toujours la metadata). */ export async function handleSubscriptionUpdate(subscription: Stripe.Subscription): Promise { const orgId = subscription.metadata?.['organization_id'] if (orgId) { await applySubscriptionToOrg(orgId, subscription) return } 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.update : org introuvable' ) return } await applySubscriptionToOrg(org.id, subscription) } /** * Handler `customer.subscription.deleted`. Bascule l'org sur Free. */ export async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise { 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 // On NE remet PAS `trialEndsAt` à null : ça reste utile pour // empêcher un user de relancer un trial après avoir cancel. await org.save() logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)') } /** * Handler `invoice.payment_failed`. Marque l'org en past_due (l'UI * affichera la bannière "votre paiement a échoué"). */ export async function handlePaymentFailed(invoice: Stripe.Invoice): Promise { 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' ) } /** * Enqueueur de l'email recap d'essai. Indirection module-level qui * permet aux tests d'injecter un spy via `__setTrialRecapEnqueueForTests` * sans avoir besoin d'un Redis mock. En prod ça résout vers * `#jobs/send_trial_recap_email_job#enqueueTrialRecapEmail` via dynamic * import (évite le cycle services ↔ jobs au load). */ let _enqueueTrialRecap: (orgId: string, subscriptionId: string) => Promise = async (orgId, subscriptionId) => { const { enqueueTrialRecapEmail } = await import('#jobs/send_trial_recap_email_job') return enqueueTrialRecapEmail(orgId, subscriptionId) } /** * **Test-only.** Override l'enqueueur du recap. Permet aux tests de * vérifier que `handleTrialWillEnd` enqueue pour la bonne org sans * démarrer un Worker BullMQ. */ export function __setTrialRecapEnqueueForTests( fn: (orgId: string, subscriptionId: string) => Promise ): void { _enqueueTrialRecap = fn } /** * Handler `customer.subscription.trial_will_end`. Émis par Stripe 3 * jours avant `trial_end` (non-configurable). On enqueue le job * d'envoi de l'email recap. * * Le job est idempotent via `jobId` BullMQ déterministe basé sur * subscriptionId — un re-deliver Stripe ne crée pas un 2e envoi. */ export async function handleTrialWillEnd(subscription: Stripe.Subscription): Promise { 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 }, 'trial_will_end : org introuvable') return } await _enqueueTrialRecap(org.id, subscription.id) logger.info( { orgId: org.id, subscriptionId: subscription.id }, 'trial_will_end : job recap enqueué' ) }