rubis/apps/api/app/controllers/billing_controller.ts
ordinarthur b0e6f83655
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m19s
Build & Deploy API / build-and-deploy (push) Successful in 1m44s
Build & Deploy Web / build-and-deploy (push) Successful in 41s
feat(billing): essai 14 j Pro avec CB à l'inscription (Stripe trial_period_days)
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>
2026-05-18 12:04:41 +02:00

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