diff --git a/apps/api/app/controllers/billing_controller.ts b/apps/api/app/controllers/billing_controller.ts new file mode 100644 index 0000000..e981f9b --- /dev/null +++ b/apps/api/app/controllers/billing_controller.ts @@ -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 { + 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 + } +} diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts index a06ad96..7b108f5 100644 --- a/apps/api/app/controllers/import_batches_controller.ts +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -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, diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 61f23e5..655e3d8 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -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) { diff --git a/apps/api/app/services/billing.ts b/apps/api/app/services/billing.ts new file mode 100644 index 0000000..5c19a07 --- /dev/null +++ b/apps/api/app/services/billing.ts @@ -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 = { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/apps/api/app/services/stripe.ts b/apps/api/app/services/stripe.ts new file mode 100644 index 0000000..a1815ee --- /dev/null +++ b/apps/api/app/services/stripe.ts @@ -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 { + 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 +} diff --git a/apps/api/commands/stripe_setup.ts b/apps/api/commands/stripe_setup.ts new file mode 100644 index 0000000..7a9149f --- /dev/null +++ b/apps/api/commands/stripe_setup.ts @@ -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 + }) { + 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 + } +} diff --git a/apps/api/database/migrations/1778157876956_alter_organizations_table.ts b/apps/api/database/migrations/1778157876956_alter_organizations_table.ts new file mode 100644 index 0000000..3248386 --- /dev/null +++ b/apps/api/database/migrations/1778157876956_alter_organizations_table.ts @@ -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') + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 5fde5fe..c86d2b1 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -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() diff --git a/apps/api/package.json b/apps/api/package.json index 1dacc74..69dd6cf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": [ diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts index 45f2e4b..81f9f23 100644 --- a/apps/api/start/env.ts +++ b/apps/api/start/env.ts @@ -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 }), diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index d232824..580c8cc 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -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`. diff --git a/apps/web/src/components/billing/PlanLimitBanner.tsx b/apps/web/src/components/billing/PlanLimitBanner.tsx new file mode 100644 index 0000000..f3c1304 --- /dev/null +++ b/apps/web/src/components/billing/PlanLimitBanner.tsx @@ -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 ( +
+
+ ); + } + + const ratio = sub.activeInvoicesCount / limit; + if (ratio < 0.8) return null; + + const reached = sub.activeInvoicesCount >= limit; + + return ( +
+
+ ); +} diff --git a/apps/web/src/lib/billing.ts b/apps/web/src/lib/billing.ts new file mode 100644 index 0000000..e1f2207 --- /dev/null +++ b/apps/web/src/lib/billing.ts @@ -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("/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; +} diff --git a/apps/web/src/routes/_app/factures.tsx b/apps/web/src/routes/_app/factures.tsx index 37dba4c..76396ba 100644 --- a/apps/web/src/routes/_app/factures.tsx +++ b/apps/web/src/routes/_app/factures.tsx @@ -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() { + + { + 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."); }, }); diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx index 0f0b7e3..8444179 100644 --- a/apps/web/src/routes/_app/parametres.tsx +++ b/apps/web/src/routes/_app/parametres.tsx @@ -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 (
@@ -64,6 +71,39 @@ function ParametresPage() { + + Plan & facturation + + } + description="Votre plan courant, votre limite de factures actives, et l'accès au portail Stripe pour gérer la CB et l'annulation." + > + +
+

+ Plan actuel +

+

+ Rubis {planLabel} + {sub?.inGracePeriod && ( + + · 3 mois offerts + + )} +

+
+ +
+
+ ("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 ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Card plan courant +// --------------------------------------------------------------------------- + +function CurrentPlanCard({ + state, + onOpenPortal, + isOpeningPortal, +}: { + state: ReturnType["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 ( + +
+
+ Plan actuel +

+ Rubis {planLabel(plan)} + {state.subscriptionStatus && state.subscriptionStatus !== "active" && ( + + · {state.subscriptionStatus} + + )} +

+ {state.currentPeriodEnd && ( +

+ Prochaine facture le{" "} + + {formatDate(state.currentPeriodEnd)} + + {state.billingCycle === "yearly" ? " (annuel)" : " (mensuel)"} +

+ )} + {plan === "free" && inGracePeriod && gracePeriodEndsAt && ( +

+

+ )} +
+ {state.hasStripeCustomer && ( + + )} +
+ + {/* Compteur de factures actives — visible pour Free uniquement */} + {isLimited && limit !== null && ( +
+
+

+ Factures actives en relance +

+

+ {activeInvoicesCount} / {limit} +

+
+
+
+
+ {limitReached && ( +

+ Limite atteinte — passez Pro pour ajouter de nouvelles factures. +

+ )} +
+ )} + + ); +} + +// --------------------------------------------------------------------------- +// Toggle cycle +// --------------------------------------------------------------------------- + +function CycleToggle({ + value, + onChange, +}: { + value: BillingCycle; + onChange: (v: BillingCycle) => void; +}) { + return ( +
+ {(["monthly", "yearly"] as const).map((v) => { + const active = v === value; + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 = { + 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" ? : plan === "business" ? : null; + + return ( + +
+ {planIcon && {planIcon}} +

+ {planLabel(plan)} +

+ {highlight && !isCurrent && ( + + Recommandé + + )} + {isCurrent && ( + + Actuel + + )} +
+ +
+

+ + {price === 0 ? "0" : price} + + + {cycle === "monthly" ? "€/mois" : "€/an"} + +

+ {price !== null && price > 0 && cycle === "yearly" && ( +

+ soit {(price / 12).toFixed(2).replace(".", ",")} €/mois +

+ )} + {plan === "free" && ( +

3 mois illimités, puis 5 factures actives

+ )} +
+ +
    + {features.map((f) => ( +
  • +
  • + ))} +
+ + {!ctaDisabled && onUpgrade ? ( + + ) : ( + + )} +
+ ); +} + +function planLabel(plan: PlanKey): string { + return plan === "free" ? "Free" : plan === "pro" ? "Pro" : "Business"; +} diff --git a/docs/flow.md b/docs/flow.md index 521a51d..1293cc1 100644 --- a/docs/flow.md +++ b/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` — `` : + - 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 | |---|---| diff --git a/landing/site.webmanifest b/landing/site.webmanifest index 255a4b5..69e384a 100644 --- a/landing/site.webmanifest +++ b/landing/site.webmanifest @@ -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" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46b4607..c87bc27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: