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>
This commit is contained in:
parent
d410ae014e
commit
1952265217
327
apps/api/app/controllers/billing_controller.ts
Normal file
327
apps/api/app/controllers/billing_controller.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
172
apps/api/app/services/billing.ts
Normal file
172
apps/api/app/services/billing.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
63
apps/api/app/services/stripe.ts
Normal file
63
apps/api/app/services/stripe.ts
Normal 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 été 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
|
||||
}
|
||||
143
apps/api/commands/stripe_setup.ts
Normal file
143
apps/api/commands/stripe_setup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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 }),
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
97
apps/web/src/components/billing/PlanLimitBanner.tsx
Normal file
97
apps/web/src/components/billing/PlanLimitBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/lib/billing.ts
Normal file
70
apps/web/src/lib/billing.ts
Normal 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;
|
||||
}
|
||||
@ -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"}
|
||||
|
||||
@ -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.");
|
||||
},
|
||||
});
|
||||
|
||||
@ -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={
|
||||
|
||||
442
apps/web/src/routes/_app/parametres_.abonnement.tsx
Normal file
442
apps/web/src/routes/_app/parametres_.abonnement.tsx
Normal 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";
|
||||
}
|
||||
89
docs/flow.md
89
docs/flow.md
@ -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 |
|
||||
|---|---|
|
||||
|
||||
@ -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
16
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user