rubis/apps/api/app/controllers/billing_controller.ts
ordinarthur 1952265217
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m0s
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy API / build-and-deploy (push) Successful in 1m52s
feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
Pricing V1 :
  - Free  : 5 factures actives, 1 user, 3 mois de grâce illimité au signup
  - Pro   : 19 €/mois ou 190 €/an, factures illimitées, 1 user
  - Business : 49 €/mois ou 490 €/an, illimité + 5 sièges (V2 multi-users)
              + reply-from-user-email (V2)

Backend :
  - Migration : plan, grace_period_ends_at, stripe_customer_id,
    stripe_subscription_id, subscription_status, billing_cycle,
    current_period_end sur `organizations`. Backfill grace_period auto.
  - `app/services/billing.ts` : PLAN_CAPS, countActiveInvoices,
    canCreateInvoices (enforce post-grace), getOrgSubscriptionState.
  - `app/services/stripe.ts` : client lazy + lookup_keys stables.
  - `app/controllers/billing_controller.ts` :
      • GET  /billing/subscription      → state pour l'UI
      • POST /billing/checkout          → crée une Checkout Session
      • POST /billing/portal            → Customer Portal Session
      • POST /billing/webhook (public)  → handle 4 events Stripe
        (checkout.completed, subscription.updated/deleted, invoice.payment_failed)
  - `commands/stripe_setup.ts` : `node ace stripe:setup` crée Products +
    Prices (idempotent via lookup_key).
  - Enforcement 402 `plan_limit_reached` sur :
      • POST /invoices (saisie manuelle)
      • POST /invoices/import-batch/:id/drafts/:draftId/validate (OCR)

Frontend :
  - `lib/billing.ts` : useSubscription, useStartCheckout, useOpenPortal,
    useIsAtFreeLimit.
  - `routes/_app/parametres_.abonnement.tsx` : page comparaison plans
    avec toggle mensuel/annuel, current plan + portail Stripe, CTA upgrade
    qui redirige vers Checkout hostée.
  - `routes/_app/parametres.tsx` : nouvelle section "Abonnement" qui
    affiche le plan courant + lien vers la page abonnement.
  - `components/billing/PlanLimitBanner.tsx` : banner sur /factures qui
    s'adapte selon période (grâce / approche / atteinte).
  - Toast dédié 402 sur la validation OCR avec action "Passer Pro".

Doc :
  - flow.md : nouvelle section §11 "Pricing & enforcement" qui couvre
    plans, grâce, webhook flow, Customer Portal, env vars.

Setup dev :
  1. STRIPE_SECRET_KEY (sk_test_...) dans apps/api/.env
  2. `stripe listen --forward-to localhost:3333/api/v1/billing/webhook`
     → copier whsec_... → STRIPE_WEBHOOK_SECRET
  3. `node ace stripe:setup` une fois pour créer Products+Prices
  4. Tester via /parametres/abonnement → checkout en mode test Stripe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:03:28 +02:00

328 lines
12 KiB
TypeScript

import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import Organization from '#models/organization'
import User from '#models/user'
import { getOrgSubscriptionState } from '#services/billing'
import { getStripe, STRIPE_LOOKUP_KEYS, getPriceByLookup } from '#services/stripe'
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']),
})
)
function lookupKeyFor(plan: 'pro' | 'business', cycle: 'monthly' | 'yearly') {
if (plan === 'pro') {
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.pro_monthly : STRIPE_LOOKUP_KEYS.pro_yearly
}
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.business_monthly : STRIPE_LOOKUP_KEYS.business_yearly
}
/**
* 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.
*/
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
}
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/checkout — auth.
* Crée une session Stripe Checkout et renvoie l'URL hostée — le SPA
* redirige vers Stripe pour que l'user paye en sécurité.
*
* 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 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`,
// On stocke org_id en metadata pour pouvoir lier côté webhook
// sans avoir besoin de regarder le customer.
subscription_data: {
metadata: { organization_id: organizationId, plan },
},
metadata: { organization_id: organizationId, plan },
allow_promotion_codes: true,
billing_address_collection: 'auto',
locale: 'fr',
})
return response.json({ data: { url: session.url } })
}
/**
* 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 :
*
* - checkout.session.completed → premier paiement OK, set plan
* - customer.subscription.updated → renouvellement, plan change
* - customer.subscription.deleted → annulation effective → free
* - invoice.payment_failed → past_due (UI rappelle l'user)
*
* 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' })
}
// Adonis a déjà parsé le body en JSON : Stripe a besoin du raw, on le
// récupère via `request.raw()`.
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 {
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
await this.handleSubscriptionUpdate(event.data.object as Stripe.Subscription)
break
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
break
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice)
break
default:
// On ignore les autres events. Stripe en envoie beaucoup, on
// n'en a besoin que d'une poignée.
break
}
} 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 })
}
// -----------------------------------------------------------------------
// Handlers webhook
// -----------------------------------------------------------------------
private async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const orgId = session.metadata?.['organization_id']
if (!orgId) {
logger.warn({ session: 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 this.applySubscriptionToOrg(orgId, subscription)
}
private async handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const orgId = subscription.metadata?.['organization_id']
if (!orgId) {
// Fallback : remonter via stripeCustomerId
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.updated : org introuvable'
)
return
}
await this.applySubscriptionToOrg(org.id, subscription)
return
}
await this.applySubscriptionToOrg(orgId, subscription)
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
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
await org.save()
logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)')
}
private async handlePaymentFailed(invoice: Stripe.Invoice) {
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')
}
/**
* Applique l'état d'une Stripe Subscription à une org : plan, cycle,
* status, period_end. Idempotent.
*/
private async applySubscriptionToOrg(orgId: string, subscription: Stripe.Subscription) {
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 as string | null
const plan = this.planFromLookupKey(lookupKey)
const cycle = this.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
await org.save()
logger.info(
{
orgId,
plan,
cycle,
status: subscription.status,
subscriptionId: subscription.id,
},
'Subscription appliquée à l\'org'
)
}
private planFromLookupKey(key: string | null): 'free' | 'pro' | 'business' {
if (!key) return 'free'
if (key.includes('business')) return 'business'
if (key.includes('pro')) return 'pro'
return 'free'
}
private cycleFromLookupKey(key: string | null): 'monthly' | 'yearly' | null {
if (!key) return null
if (key.endsWith('_yearly')) return 'yearly'
if (key.endsWith('_monthly')) return 'monthly'
return null
}
}