feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
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

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>
This commit is contained in:
ordinarthur 2026-05-07 15:03:28 +02:00
parent d410ae014e
commit 1952265217
20 changed files with 1604 additions and 8 deletions

View File

@ -0,0 +1,327 @@
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
}
}

View File

@ -12,6 +12,7 @@ import {
} from '#services/import_batch'
import { recordActivity } from '#services/activity_recorder'
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
import { canCreateInvoices } from '#services/billing'
import logger from '@adonisjs/core/services/logger'
import drive from '@adonisjs/drive/services/main'
import { createReadStream } from 'node:fs'
@ -185,6 +186,15 @@ export default class ImportBatchesController {
})
}
// Enforce le plan : si Free post-grace + déjà 5 actives → 402.
const enforcement = await canCreateInvoices(organizationId, 1)
if (!enforcement.allowed) {
throw new Exception(
`Limite atteinte : ${enforcement.limit} factures actives sur le plan Free. Passez Pro pour valider cette facture.`,
{ status: 402, code: 'plan_limit_reached' }
)
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(
organizationId,

View File

@ -11,6 +11,7 @@ import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import { canCreateInvoices } from '#services/billing'
import logger from '@adonisjs/core/services/logger'
import * as clock from '#services/clock'
import drive from '@adonisjs/drive/services/main'
@ -276,6 +277,16 @@ export default class InvoicesController {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(createInvoiceValidator)
// Plan limit Free : bloque la création si l'org a déjà 5 actives
// après la période de grâce.
const enforcement = await canCreateInvoices(organizationId, 1)
if (!enforcement.allowed) {
throw new Exception(
`Limite atteinte : ${enforcement.limit} factures actives sur le plan Free. Passez Pro pour créer cette facture.`,
{ status: 402, code: 'plan_limit_reached' }
)
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(organizationId, fields, trx)
if ('errorCode' in result) {

View File

@ -0,0 +1,172 @@
import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
/**
* Politique de plans Rubis V1 :
*
* - Free : 5 factures actives en relance, 1 user
* - Pro : factures illimitées, 1 user
* - Business : factures illimitées, 5 users (V2 multi-users), réponses
* par l'adresse mail user (V2 enhancement)
*
* Période de grâce : à l'inscription, l'org démarre en `free` avec un
* `gracePeriodEndsAt = createdAt + 3 mois`. Pendant cette fenêtre, AUCUNE
* limite n'est appliquée — l'user peut tester full power. Au-delà des 3
* mois, si `activeInvoicesCount > 5` import bloqué jusqu'à upgrade.
*/
export type PlanKey = 'free' | 'pro' | 'business'
export type PlanCaps = {
/** Nombre max de factures dans un état "actif" (pending / awaiting / in_relance / litigation). null = illimité. */
activeInvoicesLimit: number | null
/** Nombre max d'utilisateurs par org. null = illimité. */
seatsLimit: number | null
/** Multi-users autorisé ? V1 : seulement Business (mais pas implémenté). */
multiUsers: boolean
/** Réponse via l'email du user au lieu d'un from-Rubis générique ? V2. */
replyFromUserEmail: boolean
/** SMS V2. */
smsEnabled: boolean
}
export const PLAN_CAPS: Record<PlanKey, PlanCaps> = {
free: {
activeInvoicesLimit: 5,
seatsLimit: 1,
multiUsers: false,
replyFromUserEmail: false,
smsEnabled: false,
},
pro: {
activeInvoicesLimit: null,
seatsLimit: 1,
multiUsers: false,
replyFromUserEmail: false,
smsEnabled: false,
},
business: {
activeInvoicesLimit: null,
seatsLimit: 5,
multiUsers: true,
replyFromUserEmail: true,
smsEnabled: false, // V2
},
}
const ACTIVE_STATUSES = ['pending', 'awaiting_user_confirmation', 'in_relance', 'litigation']
/**
* Compte les factures considérées "actives" pour la limite Free :
* statut {pending, awaiting_user_confirmation, in_relance, litigation}.
* paid / cancelled n'occupent pas de slot.
*/
export async function countActiveInvoices(organizationId: string): Promise<number> {
const row = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('status', ACTIVE_STATUSES)
.count('* as n')
.first()
return Number(row?.n ?? 0)
}
export type EnforcementResult =
| { allowed: true }
| {
allowed: false
reason: 'free_limit_active_invoices'
limit: number
current: number
gracePeriodEndsAt: string | null
}
/**
* Vérifie si l'org peut créer N nouvelles factures actives.
*
* Règle :
* - Plans payants toujours autorisé
* - Free pendant la période de grâce autorisé sans limite
* - Free après période de grâce bloque si `current + delta > limit`
*
* `delta` = nombre de factures qu'on s'apprête à créer (typiquement 1
* pour saisie manuelle, N pour upload OCR multi-fichiers).
*/
export async function canCreateInvoices(
organizationId: string,
delta = 1
): Promise<EnforcementResult> {
const org = await Organization.find(organizationId)
if (!org) return { allowed: true } // org introuvable → pas notre rôle de bloquer ici
const plan = (org.plan ?? 'free') as PlanKey
const caps = PLAN_CAPS[plan]
if (caps.activeInvoicesLimit === null) return { allowed: true }
// Free + dans la période de grâce → unlimited
const now = DateTime.utc()
if (org.gracePeriodEndsAt && org.gracePeriodEndsAt > now) {
return { allowed: true }
}
const current = await countActiveInvoices(organizationId)
if (current + delta <= caps.activeInvoicesLimit) {
return { allowed: true }
}
return {
allowed: false,
reason: 'free_limit_active_invoices',
limit: caps.activeInvoicesLimit,
current,
gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null,
}
}
/**
* État subscription de l'org pour exposition côté SPA utilisé par la
* page /parametres/abonnement.
*/
export type OrgSubscriptionState = {
plan: PlanKey
caps: PlanCaps
/** Compteur courant de factures actives. */
activeInvoicesCount: number
/** True tant que l'org bénéficie de la fenêtre 3 mois post-signup. */
inGracePeriod: boolean
gracePeriodEndsAt: string | null
/** Status Stripe (`active`, `trialing`, `past_due`, `canceled`...). null pour les Free. */
subscriptionStatus: string | null
/** 'monthly' | 'yearly' | null pour les Free. */
billingCycle: 'monthly' | 'yearly' | null
/** ISO date de fin de période courante (= prochaine facture Stripe). */
currentPeriodEnd: string | null
/** True si l'org a un Stripe customer ID (= a déjà payé une fois). */
hasStripeCustomer: boolean
}
export async function getOrgSubscriptionState(
organizationId: string
): Promise<OrgSubscriptionState> {
const org = await Organization.findOrFail(organizationId)
const plan = (org.plan ?? 'free') as PlanKey
const now = DateTime.utc()
const inGracePeriod =
plan === 'free' && !!org.gracePeriodEndsAt && org.gracePeriodEndsAt > now
return {
plan,
caps: PLAN_CAPS[plan],
activeInvoicesCount: await countActiveInvoices(organizationId),
inGracePeriod,
gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null,
subscriptionStatus: org.subscriptionStatus ?? null,
billingCycle:
org.billingCycle === 'monthly' || org.billingCycle === 'yearly'
? org.billingCycle
: null,
currentPeriodEnd: org.currentPeriodEnd?.toISO() ?? null,
hasStripeCustomer: !!org.stripeCustomerId,
}
}

View File

@ -0,0 +1,63 @@
import Stripe from 'stripe'
import env from '#start/env'
/**
* Singleton client Stripe lazy init pour ne pas crasher en dev/test
* quand la clé n'est pas définie. Toute fonction qui nécessite Stripe
* appelle `getStripe()` qui throw si la clé manque.
*/
let _stripe: Stripe | null = null
export function getStripe(): Stripe {
if (_stripe) return _stripe
const key = env.get('STRIPE_SECRET_KEY')
if (!key) {
throw new Error(
'STRIPE_SECRET_KEY manquante. Configurer la clé dans .env avant d\'utiliser le billing.'
)
}
_stripe = new Stripe(key, {
apiVersion: '2026-04-22.dahlia',
typescript: true,
appInfo: {
name: 'Rubis Sur l\'Ongle',
version: '1.0.0',
},
})
return _stripe
}
/**
* Lookup keys utilisés pour identifier les Prices Stripe sans hardcoder
* d'IDs en env. Les Prices sont créées par `node ace stripe:setup` avec
* ces lookup_keys, et le code les retrouve via `prices.list({lookup_keys})`.
*/
export const STRIPE_LOOKUP_KEYS = {
pro_monthly: 'rubis_pro_monthly',
pro_yearly: 'rubis_pro_yearly',
business_monthly: 'rubis_business_monthly',
business_yearly: 'rubis_business_yearly',
} as const
export type StripeLookupKey = (typeof STRIPE_LOOKUP_KEYS)[keyof typeof STRIPE_LOOKUP_KEYS]
/**
* Récupère un Price Stripe via son lookup_key. Throw si introuvable
* (signal que `stripe:setup` n'a pas é lancé ou que les lookup_keys
* ont changé).
*/
export async function getPriceByLookup(key: StripeLookupKey): Promise<Stripe.Price> {
const stripe = getStripe()
const result = await stripe.prices.list({
lookup_keys: [key],
limit: 1,
expand: ['data.product'],
})
const price = result.data[0]
if (!price) {
throw new Error(
`Stripe Price introuvable pour lookup_key="${key}". Lancer \`node ace stripe:setup\` ?`
)
}
return price
}

View File

@ -0,0 +1,143 @@
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { getStripe, STRIPE_LOOKUP_KEYS } from '#services/stripe'
/**
* Crée / met à jour les Products + Prices Stripe pour Rubis.
*
* Idempotent : on cherche par `lookup_key` avant de créer. Si déjà
* existant, on log "OK" et on passe. Pas d'écrasement (les prices Stripe
* sont immuables on ne peut pas modifier le montant d'un Price existant,
* il faut en créer un nouveau).
*
* Lance ce command UNE FOIS au setup initial (test ou prod), puis quand
* tu veux ajouter de nouveaux Prices.
*
* node ace stripe:setup
*
* Pour rebattre les cartes : aller manuellement archive les Prices/Products
* dans le dashboard Stripe puis relancer.
*/
export default class StripeSetup extends BaseCommand {
static commandName = 'stripe:setup'
static description = 'Crée les Products et Prices Stripe (Pro / Business, monthly + yearly)'
static options: CommandOptions = {
startApp: true,
}
async run() {
// Lazy validation : provoque l'exception immédiate si la clé manque.
getStripe()
this.logger.info('Stripe setup — création des Products + Prices')
// ---------------- PRO ----------------
const proProduct = await this.ensureProduct({
name: 'Rubis Pro',
description:
"Factures illimitées, OCR illimité, automatisation complète des relances. Pour les TPE actives qui ne veulent plus jamais y penser.",
metadata: { plan_key: 'pro' },
})
await this.ensurePrice({
productId: proProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.pro_monthly,
unitAmount: 1900, // 19,00 €
interval: 'month',
nickname: 'Pro mensuel',
})
await this.ensurePrice({
productId: proProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.pro_yearly,
// 19 € × 12 = 228 €. -17% (= ~190 €) pour récompenser l'engagement annuel.
unitAmount: 19_000,
interval: 'year',
nickname: 'Pro annuel (-17%)',
})
// ---------------- BUSINESS ----------------
const bizProduct = await this.ensureProduct({
name: 'Rubis Business',
description:
"Factures illimitées + 5 sièges utilisateurs + réponses depuis l'email de l'utilisateur. Pour les PME qui ont une vraie équipe.",
metadata: { plan_key: 'business' },
})
await this.ensurePrice({
productId: bizProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.business_monthly,
unitAmount: 4900, // 49,00 €
interval: 'month',
nickname: 'Business mensuel',
})
await this.ensurePrice({
productId: bizProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.business_yearly,
// 49 € × 12 = 588 €. -17% (= ~490 €) annuel.
unitAmount: 49_000,
interval: 'year',
nickname: 'Business annuel (-17%)',
})
this.logger.success('Stripe setup terminé.')
}
private async ensureProduct(input: {
name: string
description: string
metadata: Record<string, string>
}) {
const stripe = getStripe()
// Lookup via metadata (Stripe ne permet pas de lookup_key sur Product,
// seulement sur Price). On utilise donc list + filter.
const existing = await stripe.products.list({ active: true, limit: 100 })
const found = existing.data.find(
(p) => p.metadata?.['plan_key'] === input.metadata['plan_key']
)
if (found) {
this.logger.info(` Product ${input.name} : déjà existant (${found.id})`)
return found
}
const created = await stripe.products.create({
name: input.name,
description: input.description,
metadata: input.metadata,
})
this.logger.success(` Product ${input.name} créé : ${created.id}`)
return created
}
private async ensurePrice(input: {
productId: string
lookupKey: string
unitAmount: number
interval: 'month' | 'year'
nickname: string
}) {
const stripe = getStripe()
const existing = await stripe.prices.list({
lookup_keys: [input.lookupKey],
limit: 1,
})
if (existing.data[0]) {
this.logger.info(
` Price ${input.nickname} : déjà existant (${existing.data[0].id})`
)
return existing.data[0]
}
const created = await stripe.prices.create({
product: input.productId,
unit_amount: input.unitAmount,
currency: 'eur',
recurring: { interval: input.interval },
lookup_key: input.lookupKey,
nickname: input.nickname,
})
this.logger.success(
` Price ${input.nickname} créé : ${created.id} (lookup=${input.lookupKey})`
)
return created
}
}

View File

@ -0,0 +1,56 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Subscription / billing chaque org a un plan + des refs Stripe pour
* piloter abonnement & encaissement.
*
* - plan : 'free' (default) | 'pro' | 'business'
* - grace_period_ends_at : 3 mois après l'inscription (free unlimited
* pendant cette fenêtre au-delà, bloqué à 5 factures actives).
* - stripe_customer_id / stripe_subscription_id : nullable jusqu'à
* la 1re souscription. Stockés pour pouvoir piloter le Customer Portal.
* - subscription_status : copié du Stripe webhook (`active`, `trialing`,
* `past_due`, `canceled`, `incomplete`, `unpaid`).
* - current_period_end : pour afficher "votre abonnement renouvelle le X".
* - billing_cycle : 'monthly' | 'yearly' facilite l'affichage UI sans
* re-fetcher Stripe à chaque rendu.
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('plan', 20).notNullable().defaultTo('free')
table.timestamp('grace_period_ends_at', { useTz: true }).nullable()
table.string('stripe_customer_id', 255).nullable().unique()
table.string('stripe_subscription_id', 255).nullable().unique()
table.string('subscription_status', 30).nullable()
table.string('billing_cycle', 10).nullable() // 'monthly' | 'yearly'
table.timestamp('current_period_end', { useTz: true }).nullable()
})
// Backfill : pour les orgs déjà créées, on pose grace_period_ends_at
// à created_at + 3 mois (équivaut à un signup démarrant maintenant).
this.defer(async (db) => {
await db
.from(this.tableName)
.whereNull('grace_period_ends_at')
.update({
grace_period_ends_at: db.raw(`created_at + interval '3 months'`),
})
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('plan')
table.dropColumn('grace_period_ends_at')
table.dropColumn('stripe_customer_id')
table.dropColumn('stripe_subscription_id')
table.dropColumn('subscription_status')
table.dropColumn('billing_cycle')
table.dropColumn('current_period_end')
})
}
}

View File

@ -218,14 +218,20 @@ export class InvoiceSchema extends BaseModel {
}
export class OrganizationSchema extends BaseModel {
static $columns = ['createdAt', 'demoMode', 'demoSpeedFactor', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt', 'virtualNow'] as const
static $columns = ['billingCycle', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const
$columns = OrganizationSchema.$columns
@column()
declare billingCycle: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime()
declare currentPeriodEnd: DateTime | null
@column()
declare demoMode: boolean
@column()
declare demoSpeedFactor: number
@column.dateTime()
declare gracePeriodEndsAt: DateTime | null
@column({ isPrimary: true })
declare id: string
@column()
@ -235,9 +241,17 @@ export class OrganizationSchema extends BaseModel {
@column.dateTime()
declare onboardingCompletedAt: DateTime | null
@column()
declare plan: string
@column()
declare rubisCount: number
@column()
declare siret: string | null
@column()
declare stripeCustomerId: string | null
@column()
declare stripeSubscriptionId: string | null
@column()
declare subscriptionStatus: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column.dateTime()

View File

@ -84,7 +84,8 @@
"luxon": "^3.7.2",
"pg": "^8.20.0",
"react": "^19.2.5",
"reflect-metadata": "^0.2.2"
"reflect-metadata": "^0.2.2",
"stripe": "^22.1.1"
},
"hotHook": {
"boundaries": [

View File

@ -59,6 +59,12 @@ export default await Env.create(new URL('../', import.meta.url), {
OCR_PROVIDER: Env.schema.enum.optional(['mock', 'mistral'] as const),
MISTRAL_API_KEY: Env.schema.string.optional(),
// Stripe — secret key + webhook signing secret. Optional en dev sans
// billing actif. La commande `stripe:setup` et le webhook handler les
// exigent au runtime.
STRIPE_SECRET_KEY: Env.schema.string.optional(),
STRIPE_WEBHOOK_SECRET: Env.schema.string.optional(),
// Web (URL du SPA pour redirects post-checkin)
WEB_URL: Env.schema.string.optional({ format: 'url', tld: false }),

View File

@ -168,6 +168,28 @@ router
.as('plans')
.use(middleware.auth())
/**
* Billing / Stripe auth requise pour subscription/checkout/portal.
* Le webhook est PUBLIC (signature Stripe vérifiée côté handler).
*/
router
.post('billing/webhook', [controllers.Billing, 'webhook'])
.as('billing.webhook')
router
.group(() => {
router
.get('subscription', [controllers.Billing, 'subscription'])
.as('subscription')
router
.post('checkout', [controllers.Billing, 'checkout'])
.as('checkout')
router.post('portal', [controllers.Billing, 'portal']).as('portal')
})
.prefix('billing')
.as('billing')
.use(middleware.auth())
/**
* Demo auth requise. Mode démo opt-in par org (cf. CLAUDE.md
* Architecture). Routes opérantes seulement si `org.demo_mode = true`.

View File

@ -0,0 +1,97 @@
import { Link } from "@tanstack/react-router";
import { ArrowRight, Sparkles, Zap } from "lucide-react";
import { useSubscription } from "@/lib/billing";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format";
/**
* Banner d'enforcement plan Free.
*
* - Hidden : plan Pro/Business OU période de grâce active
* - "Approche" : 4-5 factures sur 5 ton conseil
* - "Atteinte" : 5 factures sur 5 ton blocant + CTA upgrade
*
* Posé en haut de /factures et /factures/import. Pas dans le dashboard
* pour ne pas polluer la lecture des KPIs.
*/
export function PlanLimitBanner({ className }: { className?: string }) {
const { data: sub } = useSubscription();
if (!sub) return null;
if (sub.plan !== "free") return null;
const limit = sub.caps.activeInvoicesLimit;
if (limit === null) return null;
// En grace period → on affiche un mini-rappel doux, pas un blocking banner.
if (sub.inGracePeriod && sub.gracePeriodEndsAt) {
return (
<div
className={cn(
"rounded-card border border-rubis-glow bg-rubis-glow/30 px-4 py-3",
"flex items-center gap-3 text-[12.5px] text-ink-2",
className,
)}
>
<Sparkles size={14} className="text-rubis shrink-0" aria-hidden="true" />
<p className="leading-snug">
<strong className="text-ink font-semibold">Période de grâce</strong>{" "}
illimité jusqu'au{" "}
<strong className="font-medium">
{formatDate(sub.gracePeriodEndsAt)}
</strong>
. Au-delà, le plan Free est plafonné à {limit} factures actives.
</p>
</div>
);
}
const ratio = sub.activeInvoicesCount / limit;
if (ratio < 0.8) return null;
const reached = sub.activeInvoicesCount >= limit;
return (
<div
className={cn(
"rounded-card border px-4 py-3.5 flex items-start gap-3",
reached
? "border-rubis-deep bg-rubis-glow/40"
: "border-rubis bg-rubis-glow/20",
className,
)}
>
<Zap
size={16}
className={cn(
"shrink-0 mt-0.5",
reached ? "text-rubis-deep" : "text-rubis",
)}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<p className="font-display text-[14px] font-semibold text-ink leading-tight">
{reached
? "Limite Free atteinte"
: `Bientôt à la limite (${sub.activeInvoicesCount}/${limit})`}
</p>
<p className="mt-1 text-[12.5px] text-ink-2 leading-snug">
{reached
? "Vous avez utilisé vos 5 factures actives gratuites. Passez Pro pour continuer à importer et relancer sans contrainte."
: "Vous approchez de la limite Free. Passer Pro maintenant évite l'interruption."}
</p>
</div>
<Link
to="/parametres/abonnement"
className={cn(
"shrink-0 inline-flex items-center gap-1.5 rounded-default px-3 py-2 cursor-pointer",
"text-[12.5px] font-semibold transition-colors",
reached
? "bg-rubis text-white hover:bg-rubis-deep"
: "bg-white border border-rubis text-rubis hover:bg-rubis hover:text-white",
)}
>
Passer Pro <ArrowRight size={12} aria-hidden="true" />
</Link>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
/** Identique au type côté API (`OrgSubscriptionState`). */
export type PlanKey = "free" | "pro" | "business";
export type BillingCycle = "monthly" | "yearly";
export type SubscriptionState = {
plan: PlanKey;
caps: {
activeInvoicesLimit: number | null;
seatsLimit: number | null;
multiUsers: boolean;
replyFromUserEmail: boolean;
smsEnabled: boolean;
};
activeInvoicesCount: number;
inGracePeriod: boolean;
gracePeriodEndsAt: string | null;
subscriptionStatus: string | null;
billingCycle: BillingCycle | null;
currentPeriodEnd: string | null;
hasStripeCustomer: boolean;
};
/** Lit l'état de l'abonnement courant. */
export function useSubscription() {
return useQuery({
queryKey: ["billing", "subscription"] as const,
queryFn: () =>
api.get<SubscriptionState>("/api/v1/billing/subscription"),
staleTime: 30_000,
});
}
/**
* Lance le checkout Stripe pour upgrader vers Pro / Business.
* Renvoie l'URL hostée Stripe le caller doit `window.location.href = url`.
*/
export function useStartCheckout() {
return useMutation({
mutationFn: ({ plan, cycle }: { plan: "pro" | "business"; cycle: BillingCycle }) =>
api.post<{ url: string }>("/api/v1/billing/checkout", { plan, cycle }),
});
}
/**
* Ouvre le Customer Portal Stripe pour gérer abonnement / CB / annuler.
* Disponible seulement si l'org a déjà un Stripe customer.
*/
export function useOpenPortal() {
return useMutation({
mutationFn: () => api.post<{ url: string }>("/api/v1/billing/portal"),
});
}
/**
* True si l'org est sur Free, hors grace period, et limit. Le SPA
* l'utilise pour afficher un banner "limite atteinte" et bloquer
* l'upload côté UI avant même de toucher l'API.
*/
export function useIsAtFreeLimit(): boolean {
const { data: state } = useSubscription();
if (!state) return false;
if (state.plan !== "free") return false;
if (state.inGracePeriod) return false;
const limit = state.caps.activeInvoicesLimit;
if (limit === null) return false;
return state.activeInvoicesCount >= limit;
}

View File

@ -13,6 +13,7 @@ import { queryKeys } from "@/lib/queryKeys";
import { Dropzone } from "@/components/factures/Dropzone";
import { FilterChips, type FilterOption } from "@/components/factures/FilterChips";
import { PlanLimitBanner } from "@/components/billing/PlanLimitBanner";
import {
InvoiceTable,
type InvoiceListItem,
@ -196,6 +197,8 @@ function FacturesPage() {
</Button>
</div>
<PlanLimitBanner />
<FilterChips
options={filterOptions}
value={(search.status as FilterKey | undefined) ?? "all"}

View File

@ -19,7 +19,7 @@ import {
import { toast } from "sonner";
import type { Invoice, Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { api, ApiError } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { formatEuros } from "@/lib/format";
@ -130,7 +130,23 @@ function ImportReviewPage() {
void queryClient.invalidateQueries({ queryKey: ["invoices", "counts"] });
toast.success("Facture validée. + 1 rubis.");
},
onError: () => {
onError: (err: unknown) => {
// 402 → limite Free atteinte. Message dédié + propose l'upgrade.
if (
err instanceof ApiError &&
err.status === 402 &&
err.code === "plan_limit_reached"
) {
toast.error(err.message ?? "Limite Free atteinte — passez Pro.", {
action: {
label: "Passer Pro",
onClick: () =>
void navigate({ to: "/parametres/abonnement" }),
},
duration: 8000,
});
return;
}
toast.error("Impossible de valider la facture. Vérifiez les champs.");
},
});

View File

@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowRight, CreditCard } from "lucide-react";
import { SettingsSection } from "@/components/settings/SettingsSection";
import { AccountForm } from "@/components/settings/AccountForm";
@ -6,6 +7,9 @@ import { OrganizationForm } from "@/components/settings/OrganizationForm";
import { SignatureForm } from "@/components/settings/SignatureForm";
import { DangerZone } from "@/components/settings/DangerZone";
import { DemoToggle } from "@/components/demo/DemoToggle";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { useSubscription } from "@/lib/billing";
export const Route = createFileRoute("/_app/parametres")({
component: ParametresPage,
@ -24,6 +28,9 @@ export const Route = createFileRoute("/_app/parametres")({
* du blast radius (modifier sa signature ne sauvegarde pas l'org, etc.).
*/
function ParametresPage() {
const { data: sub } = useSubscription();
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
return (
<div className="flex flex-col gap-2">
<header className="mb-4">
@ -64,6 +71,39 @@ function ParametresPage() {
<SignatureForm />
</SettingsSection>
<SettingsSection
eyebrow="Abonnement"
title={
<>
Plan & <em className="text-rubis">facturation</em>
</>
}
description="Votre plan courant, votre limite de factures actives, et l'accès au portail Stripe pour gérer la CB et l'annulation."
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Plan actuel
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
Rubis {planLabel}
{sub?.inGracePeriod && (
<span className="ml-2 text-[11px] font-medium text-rubis-deep uppercase tracking-[0.1em]">
· 3 mois offerts
</span>
)}
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/abonnement">
<CreditCard size={14} aria-hidden="true" />
Gérer l'abonnement
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
</Card>
</SettingsSection>
<SettingsSection
eyebrow="Démonstration"
title={

View File

@ -0,0 +1,442 @@
import { useEffect, useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import {
ArrowLeft,
ArrowRight,
Check,
CreditCard,
Sparkles,
Users,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
import { useOpenPortal, useStartCheckout, useSubscription, type BillingCycle, type PlanKey } from "@/lib/billing";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
const searchSchema = z.object({
checkout: z.enum(["success", "cancel"]).optional(),
session_id: z.string().optional(),
});
export const Route = createFileRoute("/_app/parametres_/abonnement")({
validateSearch: searchSchema,
component: AbonnementPage,
});
/**
* /parametres/abonnement gestion du plan et de la facturation Rubis.
*
* Sections :
* - Plan actuel + caps + état grace period
* - Comparaison Free / Pro / Business avec toggle mensuel/annuel
* - CTA upgrade (Stripe Checkout) ou portail (Customer Portal)
*/
function AbonnementPage() {
const search = Route.useSearch();
const { data: sub, isPending } = useSubscription();
const checkout = useStartCheckout();
const portal = useOpenPortal();
const [cycle, setCycle] = useState<BillingCycle>("yearly");
// Toast post-redirect Stripe
useEffect(() => {
if (search.checkout === "success") {
toast.success("Bienvenue sur le nouveau plan ! Activation en cours…");
} else if (search.checkout === "cancel") {
toast.info("Checkout annulé. Pas de souci, on en reste là.");
}
}, [search.checkout]);
const onUpgrade = (plan: "pro" | "business") => {
checkout.mutate(
{ plan, cycle },
{
onSuccess: ({ url }) => {
window.location.href = url;
},
onError: () => toast.error("Impossible d'ouvrir Stripe. Réessaye."),
},
);
};
const onOpenPortal = () => {
portal.mutate(undefined, {
onSuccess: ({ url }) => {
window.location.href = url;
},
onError: () => toast.error("Impossible d'ouvrir le portail. Réessaye."),
});
};
return (
<div className="flex flex-col gap-6">
<Link
to="/parametres"
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
>
<ArrowLeft size={13} aria-hidden="true" /> Paramètres
</Link>
<header>
<Eyebrow>Abonnement</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Choisir <em className="text-rubis">son plan</em>
</h1>
<p className="mt-1.5 text-[14px] text-ink-3 max-w-2xl leading-relaxed">
Trois mois offerts au démarrage. Au-delà, passez Pro pour
continuer à relancer plus de 5 factures à la fois.
</p>
</header>
{/* Plan courant */}
{!isPending && sub && (
<CurrentPlanCard
state={sub}
onOpenPortal={onOpenPortal}
isOpeningPortal={portal.isPending}
/>
)}
{/* Toggle mensuel / annuel */}
<div className="self-start">
<CycleToggle value={cycle} onChange={setCycle} />
</div>
{/* Cards plans */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-5">
<PlanCard
plan="free"
currentPlan={sub?.plan}
cycle={cycle}
ctaDisabled
ctaLabel={sub?.plan === "free" ? "Plan actuel" : "Plan gratuit"}
/>
<PlanCard
plan="pro"
currentPlan={sub?.plan}
cycle={cycle}
highlight
loading={checkout.isPending && checkout.variables?.plan === "pro"}
onUpgrade={() => onUpgrade("pro")}
/>
<PlanCard
plan="business"
currentPlan={sub?.plan}
cycle={cycle}
loading={checkout.isPending && checkout.variables?.plan === "business"}
onUpgrade={() => onUpgrade("business")}
/>
</section>
<p className="text-[12px] text-ink-3 italic max-w-2xl leading-relaxed">
Paiement sécurisé via Stripe (CB, SEPA). TVA selon votre pays.
Annulation possible à tout moment depuis le portail client. Aucun
engagement.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Card plan courant
// ---------------------------------------------------------------------------
function CurrentPlanCard({
state,
onOpenPortal,
isOpeningPortal,
}: {
state: ReturnType<typeof useSubscription>["data"] & {};
onOpenPortal: () => void;
isOpeningPortal: boolean;
}) {
const { plan, activeInvoicesCount, caps, inGracePeriod, gracePeriodEndsAt } = state;
const isLimited = plan === "free" && caps.activeInvoicesLimit !== null;
const limit = caps.activeInvoicesLimit;
const limitReached =
isLimited && limit !== null && !inGracePeriod && activeInvoicesCount >= limit;
return (
<Card padding="md" className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<Eyebrow tone="ink">Plan actuel</Eyebrow>
<p className="mt-2 font-display text-[22px] font-bold text-ink">
Rubis {planLabel(plan)}
{state.subscriptionStatus && state.subscriptionStatus !== "active" && (
<span className="ml-2 text-[12px] font-medium text-rubis-deep uppercase tracking-[0.1em]">
· {state.subscriptionStatus}
</span>
)}
</p>
{state.currentPeriodEnd && (
<p className="mt-1 text-[12.5px] text-ink-3">
Prochaine facture le{" "}
<strong className="font-medium text-ink-2">
{formatDate(state.currentPeriodEnd)}
</strong>
{state.billingCycle === "yearly" ? " (annuel)" : " (mensuel)"}
</p>
)}
{plan === "free" && inGracePeriod && gracePeriodEndsAt && (
<p className="mt-1 text-[12.5px] text-rubis-deep">
<Sparkles size={12} className="inline mr-1" aria-hidden="true" />
Période de grâce illimité jusqu'au{" "}
<strong className="font-semibold">
{formatDate(gracePeriodEndsAt)}
</strong>
</p>
)}
</div>
{state.hasStripeCustomer && (
<Button
size="sm"
variant="secondary"
onClick={onOpenPortal}
loading={isOpeningPortal}
>
<CreditCard size={14} aria-hidden="true" /> Gérer
</Button>
)}
</div>
{/* Compteur de factures actives — visible pour Free uniquement */}
{isLimited && limit !== null && (
<div>
<div className="flex items-baseline justify-between mb-1">
<p className="text-[12.5px] text-ink-3">
Factures actives en relance
</p>
<p
className={cn(
"text-[12.5px] font-semibold tabular-nums",
limitReached ? "text-rubis-deep" : "text-ink-2",
)}
>
{activeInvoicesCount} / {limit}
</p>
</div>
<div className="h-1.5 w-full rounded-full bg-cream-2 overflow-hidden">
<div
className={cn(
"h-full transition-[width] duration-300",
limitReached ? "bg-rubis-deep" : "bg-rubis",
)}
style={{
width: `${Math.min(100, (activeInvoicesCount / limit) * 100)}%`,
}}
/>
</div>
{limitReached && (
<p className="mt-2 text-[12.5px] text-rubis-deep font-medium">
Limite atteinte passez Pro pour ajouter de nouvelles factures.
</p>
)}
</div>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Toggle cycle
// ---------------------------------------------------------------------------
function CycleToggle({
value,
onChange,
}: {
value: BillingCycle;
onChange: (v: BillingCycle) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Cycle de facturation"
className="inline-flex rounded-default border border-line bg-white p-0.5"
>
{(["monthly", "yearly"] as const).map((v) => {
const active = v === value;
return (
<button
key={v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(v)}
className={cn(
"px-4 h-9 rounded-default text-[12.5px] font-medium transition-colors cursor-pointer",
active ? "bg-rubis text-white" : "text-ink-2 hover:bg-cream-2",
)}
>
{v === "monthly" ? "Mensuel" : "Annuel"}
{v === "yearly" && (
<span
className={cn(
"ml-1.5 text-[10px] font-semibold uppercase tracking-[0.1em]",
active ? "text-rubis-glow" : "text-rubis",
)}
>
17%
</span>
)}
</button>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Cards plans (Free / Pro / Business)
// ---------------------------------------------------------------------------
const PRICES: Record<
PlanKey,
{ monthly: number | null; yearly: number | null }
> = {
free: { monthly: 0, yearly: 0 },
pro: { monthly: 19, yearly: 190 },
business: { monthly: 49, yearly: 490 },
};
const FEATURES_BY_PLAN: Record<PlanKey, string[]> = {
free: [
"5 factures actives en relance",
"1 utilisateur",
"Plans de relance fournis",
"OCR illimité (3 premiers mois)",
],
pro: [
"Factures illimitées",
"OCR illimité",
"Plans custom + IA générative",
"Toutes les automatisations",
"1 utilisateur",
],
business: [
"Tout le plan Pro",
"Réponses depuis votre email pro",
"5 sièges utilisateurs",
"Support prioritaire",
"SMS (à venir)",
],
};
function PlanCard({
plan,
currentPlan,
cycle,
highlight = false,
ctaDisabled = false,
ctaLabel,
loading = false,
onUpgrade,
}: {
plan: PlanKey;
currentPlan?: PlanKey;
cycle: BillingCycle;
highlight?: boolean;
ctaDisabled?: boolean;
ctaLabel?: string;
loading?: boolean;
onUpgrade?: () => void;
}) {
const isCurrent = currentPlan === plan;
const price = PRICES[plan][cycle];
const features = FEATURES_BY_PLAN[plan];
const planIcon =
plan === "pro" ? <Zap size={16} /> : plan === "business" ? <Users size={16} /> : null;
return (
<Card
padding="md"
className={cn(
"flex flex-col",
highlight && !isCurrent && "border-rubis shadow-card",
)}
>
<div className="flex items-center gap-2 mb-1">
{planIcon && <span className="text-rubis">{planIcon}</span>}
<p className="font-display text-[18px] font-bold text-ink">
{planLabel(plan)}
</p>
{highlight && !isCurrent && (
<span className="ml-auto text-[10px] uppercase tracking-[0.12em] font-semibold text-rubis bg-rubis-glow rounded-full px-2 py-0.5">
Recommandé
</span>
)}
{isCurrent && (
<span className="ml-auto text-[10px] uppercase tracking-[0.12em] font-semibold text-ink-3">
Actuel
</span>
)}
</div>
<div className="mt-2 mb-5">
<p className="font-display tabular-nums">
<span className="text-[36px] font-bold tracking-[-0.02em] text-ink">
{price === 0 ? "0" : price}
</span>
<span className="text-[14px] font-medium text-ink-3 ml-1">
{cycle === "monthly" ? "€/mois" : "€/an"}
</span>
</p>
{price !== null && price > 0 && cycle === "yearly" && (
<p className="text-[11.5px] text-ink-3">
soit {(price / 12).toFixed(2).replace(".", ",")} /mois
</p>
)}
{plan === "free" && (
<p className="text-[11.5px] text-ink-3">3 mois illimités, puis 5 factures actives</p>
)}
</div>
<ul className="flex flex-col gap-2 mb-6 flex-1">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-2 text-[13px] text-ink-2 leading-snug"
>
<Check size={13} className="text-rubis shrink-0 mt-0.5" aria-hidden="true" />
<span>{f}</span>
</li>
))}
</ul>
{!ctaDisabled && onUpgrade ? (
<Button
size="md"
variant={highlight ? "primary" : "secondary"}
loading={loading}
disabled={isCurrent}
onClick={onUpgrade}
className="w-full"
>
{isCurrent ? (
"Plan actuel"
) : (
<>
Passer {planLabel(plan)} <ArrowRight size={14} aria-hidden="true" />
</>
)}
</Button>
) : (
<Button size="md" variant="ghost" disabled className="w-full">
{ctaLabel ?? "—"}
</Button>
)}
</Card>
);
}
function planLabel(plan: PlanKey): string {
return plan === "free" ? "Free" : plan === "pro" ? "Pro" : "Business";
}

View File

@ -402,7 +402,94 @@ L'`User.signature` (posée en /parametres) est interpolée dans tous les templat
---
## 11. Ce que Rubis ne fait PAS (rappel)
## 11. Pricing & enforcement
### 11.1 Plans
| Plan | Prix mensuel | Prix annuel | Limite factures actives | Sièges | V2 |
|---|---|---|---|---|---|
| **Free** | 0 € | — | 5 (après période de grâce 3 mois) | 1 | — |
| **Pro** | 19 € | 190 € (-17%) | illimitées | 1 | — |
| **Business** | 49 € | 490 € (-17%) | illimitées | 5 (V2 multi-users) | reply-from-user-email, SMS |
"Facture active" = statut ∈ {`pending`, `awaiting_user_confirmation`, `in_relance`, `litigation`}. Les `paid` et `cancelled` ne consomment pas de slot.
### 11.2 Période de grâce 3 mois
À la création de l'org : `gracePeriodEndsAt = createdAt + 3 mois`. Pendant cette fenêtre, le plan Free est **illimité** (l'user teste full power). Au-delà :
- Si `activeInvoicesCount ≤ 5` → reste Free, fonctionne normalement
- Si `activeInvoicesCount > 5` → import bloqué (HTTP 402 `plan_limit_reached`) jusqu'à upgrade
L'API qui enforce : `canCreateInvoices(organizationId, delta)` dans `app/services/billing.ts`. Appelée par :
- `POST /invoices/import-batch/:id/drafts/:draftId/validate` (validation OCR)
- `POST /invoices` (saisie manuelle)
Les uploads de PDFs ne sont PAS bloqués (le user peut empiler des drafts), seule la **validation** qui crée l'`Invoice` finale check la limite.
### 11.3 Stripe — flow technique
**Setup initial** (1 fois par compte Stripe — test ou prod) :
```bash
node ace stripe:setup
```
Crée 2 Products (Pro, Business) + 4 Prices (mensuel + annuel pour chacun) avec `lookup_key` stable (`rubis_pro_monthly`, `rubis_pro_yearly`, `rubis_business_monthly`, `rubis_business_yearly`). Idempotent.
**Flow utilisateur — upgrade Free → Pro** :
1. Click "Passer Pro" sur `/parametres/abonnement`
2. SPA appelle `POST /api/v1/billing/checkout { plan, cycle }`
3. Backend crée un Stripe Customer (si pas déjà) + une Stripe Checkout Session, retourne `{ url }`
4. SPA redirect vers Stripe (UI hostée, 3DS géré)
5. User paye → Stripe redirect `success_url = /parametres/abonnement?checkout=success`
6. **En parallèle** : Stripe envoie `checkout.session.completed` au webhook → org passe en plan Pro
**Webhook** (`POST /api/v1/billing/webhook`, public, signature vérifiée) :
- `checkout.session.completed` → premier paiement OK, set plan + subscriptionId
- `customer.subscription.updated` → renouvellement / change de plan / mise à jour status
- `customer.subscription.deleted` → annulation effective → fallback `free`
- `invoice.payment_failed` → status `past_due` (UI rappelle l'user)
Le webhook est **idempotent** : Stripe peut re-livrer plusieurs fois le même event, on traite chaque fois en read-then-write sans assumer 1-shot.
**Customer Portal** (gestion CB / annulation) :
1. Click "Gérer" sur `/parametres/abonnement`
2. SPA appelle `POST /api/v1/billing/portal`
3. Backend crée une Stripe Billing Portal Session, retourne `{ url }`
4. SPA redirect vers Stripe (CB, factures, annulation, tout est géré là)
### 11.4 Champs DB (`organizations`)
| Colonne | Type | Sens |
|---|---|---|
| `plan` | `'free' \| 'pro' \| 'business'` | Plan courant. Default `free`. |
| `grace_period_ends_at` | timestamp | `created_at + 3 mois`. NULL après upgrade. |
| `stripe_customer_id` | string | Set au 1er checkout, jamais réécrit. |
| `stripe_subscription_id` | string | Refresh à chaque webhook subscription. |
| `subscription_status` | string | `active`, `trialing`, `past_due`, `canceled`, `incomplete`, `unpaid` (mirroring Stripe). |
| `billing_cycle` | `'monthly' \| 'yearly'` | Pour l'UI. |
| `current_period_end` | timestamp | "Prochaine facture le X". |
### 11.5 UI surfaces billing
- `/parametres/abonnement` — comparaison Free/Pro/Business + toggle mensuel/annuel + plan courant + bouton portail
- `/parametres` (la page principale) — section "Abonnement" qui montre le plan courant + lien "Gérer l'abonnement"
- `/factures``<PlanLimitBanner>` :
- Pendant grâce : rappel discret "illimité jusqu'au DD/MM/YYYY"
- Approche limite (ratio ≥ 80%) : avertissement avant blocage
- Limite atteinte : banner rouge bloquant + CTA "Passer Pro"
- Toast 402 sur la validation OCR : message + bouton action "Passer Pro" qui navigue vers /parametres/abonnement
### 11.6 Variables d'environnement
```
STRIPE_SECRET_KEY=sk_test_... (ou sk_live_... en prod)
STRIPE_WEBHOOK_SECRET=whsec_... (signature du endpoint)
```
En dev local, exposer le webhook via `stripe listen --forward-to localhost:3333/api/v1/billing/webhook` (Stripe CLI requise) — la CLI affiche le `whsec_...` à mettre en env.
---
## 12. Ce que Rubis ne fait PAS (rappel)
| Hors-scope | Pourquoi |
|---|---|

View File

@ -1,5 +1,5 @@
{
"name": "Rubis Sur l'Ongle",
"name": "Rubis.",
"short_name": "Rubis",
"icons": [
{
@ -18,4 +18,4 @@
"theme_color": "#9F1239",
"background_color": "#FAF7F2",
"display": "standalone"
}
}

16
pnpm-lock.yaml generated
View File

@ -107,6 +107,9 @@ importers:
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
stripe:
specifier: ^22.1.1
version: 22.1.1(@types/node@25.6.0)
devDependencies:
'@adonisjs/assembler':
specifier: ^8.4.0
@ -5617,6 +5620,15 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.1.1:
resolution: {integrity: sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.2.3:
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
@ -11644,6 +11656,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@22.1.1(@types/node@25.6.0):
optionalDependencies:
'@types/node': 25.6.0
strnum@2.2.3: {}
strtok3@10.3.5: