rubis/apps/api/app/services/stripe_billing.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

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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é'
)
}