Implémente le chantier #6 de docs/tech/landing-optimisations.md. Le funnel signup propose maintenant un essai 14 j Pro avec carte demandée mais non prélevée — prélèvement automatique à J+14 avec rappel à J+11 (webhook customer.subscription.trial_will_end de Stripe). Couverture tests : 60 tests unitaires sur la couche billing - billing.spec.ts (25) — quota Free, bypass trial, inTrial state - stripe_billing.spec.ts (24) — handlers webhook, idempotence, dispatcher - trial_recap_job.spec.ts (11) — stats aggregation, formatRubisToHoursFr + 3 nouveaux tests vitest côté SPA (useTrialDaysRemaining, useIsAtFreeLimit bypass trial). Backend : - Migration 1779000000000_add_trial_ends_at_to_organizations - PLAN_CAPS bypass quand status=trialing AND trial_ends_at futur - getOrgSubscriptionState expose inTrial + trialEndsAt - Refactor handlers webhook en service stripe_billing.ts (pures, testables) — extraction depuis le controller. dispatchWebhookEvent routeur typé également extrait pour les tests. - createTrialCheckoutSession avec subscription_data.trial_period_days=14, garde-fou TrialAlreadyConsumedError contre re-trial. - handleTrialWillEnd → enqueue job recap (BullMQ jobId déterministe basé sur subscriptionId, idempotent contre re-delivery Stripe). - Endpoint POST /api/v1/billing/start-trial. - Email template trial_recap (React Email, branding Rubis figé) avec stats: factures importées, relances envoyées, € récupérés, rubis + heures libérées. Infra de test : - tests/helpers/stripe_mock.ts : __setStripeForTests injection + factories fakeSubscription / fakeCheckoutSession / fakeInvoice. - __setTrialRecapEnqueueForTests : permet de spy l'enqueue sans Redis. Frontend : - /onboarding/billing.tsx (opt-in, pas encore forcé dans le flow) : bouton primaire essai 14j + fallback "Free 2 factures". - PlanLimitBanner : nouveau état "Essai Pro · X jours restants" qui prime sur les autres bandeaux. Discret rubis-glow, non blocant. - useStartTrial hook + useTrialDaysRemaining (arrondi sup). - SubscriptionState typé avec inTrial + trialEndsAt. Landing : - Sous-texte CTA réactivé : « CB demandée, non prélevée avant J+14 » (Hero + FinalCTA), maintenant promesse véridique. Notes ouvertes (à décider ultérieurement) : - Tunnel /onboarding/billing FORCÉ entre signup et /onboarding/compte : guard reste à activer (risque cassage du signup actuel sinon). Pour l'instant l'écran est accessible mais opt-in. - Cron de redondance trial-recap : pas encore implémenté (le jobId déterministe BullMQ couvre déjà la double-livraison Stripe). À ajouter si on observe des trial sans recap en prod. - Tests E2E avec Stripe test mode à faire avant le go-live (cartes 3DS 4000 0027 6000 3184, declined 4000 0000 0000 0341). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
/**
|
||
* 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<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
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<CheckoutSessionResult> {
|
||
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<CheckoutSessionResult> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> =
|
||
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>
|
||
): 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<void> {
|
||
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é'
|
||
)
|
||
}
|