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>
283 lines
9.8 KiB
TypeScript
283 lines
9.8 KiB
TypeScript
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: <timestamp>` — 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<void> {
|
|
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
|
|
}
|
|
}
|