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'
|
} from '#services/import_batch'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
||||||
|
import { canCreateInvoices } from '#services/billing'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import drive from '@adonisjs/drive/services/main'
|
import drive from '@adonisjs/drive/services/main'
|
||||||
import { createReadStream } from 'node:fs'
|
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 invoice = await db.transaction(async (trx) => {
|
||||||
const result = await resolveClient(
|
const result = await resolveClient(
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { resolveClient } from '#services/resolve_client'
|
|||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { cancelFutureRelances } from '#services/relance_scheduler'
|
import { cancelFutureRelances } from '#services/relance_scheduler'
|
||||||
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
|
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
|
||||||
|
import { canCreateInvoices } from '#services/billing'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import * as clock from '#services/clock'
|
import * as clock from '#services/clock'
|
||||||
import drive from '@adonisjs/drive/services/main'
|
import drive from '@adonisjs/drive/services/main'
|
||||||
@ -276,6 +277,16 @@ export default class InvoicesController {
|
|||||||
const organizationId = requireOrgId(auth)
|
const organizationId = requireOrgId(auth)
|
||||||
const fields = await request.validateUsing(createInvoiceValidator)
|
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 invoice = await db.transaction(async (trx) => {
|
||||||
const result = await resolveClient(organizationId, fields, trx)
|
const result = await resolveClient(organizationId, fields, trx)
|
||||||
if ('errorCode' in result) {
|
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 {
|
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
|
$columns = OrganizationSchema.$columns
|
||||||
|
@column()
|
||||||
|
declare billingCycle: string | null
|
||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
|
@column.dateTime()
|
||||||
|
declare currentPeriodEnd: DateTime | null
|
||||||
@column()
|
@column()
|
||||||
declare demoMode: boolean
|
declare demoMode: boolean
|
||||||
@column()
|
@column()
|
||||||
declare demoSpeedFactor: number
|
declare demoSpeedFactor: number
|
||||||
|
@column.dateTime()
|
||||||
|
declare gracePeriodEndsAt: DateTime | null
|
||||||
@column({ isPrimary: true })
|
@column({ isPrimary: true })
|
||||||
declare id: string
|
declare id: string
|
||||||
@column()
|
@column()
|
||||||
@ -235,9 +241,17 @@ export class OrganizationSchema extends BaseModel {
|
|||||||
@column.dateTime()
|
@column.dateTime()
|
||||||
declare onboardingCompletedAt: DateTime | null
|
declare onboardingCompletedAt: DateTime | null
|
||||||
@column()
|
@column()
|
||||||
|
declare plan: string
|
||||||
|
@column()
|
||||||
declare rubisCount: number
|
declare rubisCount: number
|
||||||
@column()
|
@column()
|
||||||
declare siret: string | null
|
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 })
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
declare updatedAt: DateTime | null
|
declare updatedAt: DateTime | null
|
||||||
@column.dateTime()
|
@column.dateTime()
|
||||||
|
|||||||
@ -84,7 +84,8 @@
|
|||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"reflect-metadata": "^0.2.2"
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"stripe": "^22.1.1"
|
||||||
},
|
},
|
||||||
"hotHook": {
|
"hotHook": {
|
||||||
"boundaries": [
|
"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),
|
OCR_PROVIDER: Env.schema.enum.optional(['mock', 'mistral'] as const),
|
||||||
MISTRAL_API_KEY: Env.schema.string.optional(),
|
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 du SPA pour redirects post-checkin)
|
||||||
WEB_URL: Env.schema.string.optional({ format: 'url', tld: false }),
|
WEB_URL: Env.schema.string.optional({ format: 'url', tld: false }),
|
||||||
|
|
||||||
|
|||||||
@ -168,6 +168,28 @@ router
|
|||||||
.as('plans')
|
.as('plans')
|
||||||
.use(middleware.auth())
|
.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 →
|
* Demo — auth requise. Mode démo opt-in par org (cf. CLAUDE.md →
|
||||||
* Architecture). Routes opérantes seulement si `org.demo_mode = true`.
|
* 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 { Dropzone } from "@/components/factures/Dropzone";
|
||||||
import { FilterChips, type FilterOption } from "@/components/factures/FilterChips";
|
import { FilterChips, type FilterOption } from "@/components/factures/FilterChips";
|
||||||
|
import { PlanLimitBanner } from "@/components/billing/PlanLimitBanner";
|
||||||
import {
|
import {
|
||||||
InvoiceTable,
|
InvoiceTable,
|
||||||
type InvoiceListItem,
|
type InvoiceListItem,
|
||||||
@ -196,6 +197,8 @@ function FacturesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PlanLimitBanner />
|
||||||
|
|
||||||
<FilterChips
|
<FilterChips
|
||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
value={(search.status as FilterKey | undefined) ?? "all"}
|
value={(search.status as FilterKey | undefined) ?? "all"}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { Invoice, Plan } from "@rubis/shared";
|
import type { Invoice, Plan } from "@rubis/shared";
|
||||||
import { api } from "@/lib/api";
|
import { api, ApiError } from "@/lib/api";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatEuros } from "@/lib/format";
|
import { formatEuros } from "@/lib/format";
|
||||||
@ -130,7 +130,23 @@ function ImportReviewPage() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["invoices", "counts"] });
|
void queryClient.invalidateQueries({ queryKey: ["invoices", "counts"] });
|
||||||
toast.success("Facture validée. + 1 rubis.");
|
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.");
|
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 { SettingsSection } from "@/components/settings/SettingsSection";
|
||||||
import { AccountForm } from "@/components/settings/AccountForm";
|
import { AccountForm } from "@/components/settings/AccountForm";
|
||||||
@ -6,6 +7,9 @@ import { OrganizationForm } from "@/components/settings/OrganizationForm";
|
|||||||
import { SignatureForm } from "@/components/settings/SignatureForm";
|
import { SignatureForm } from "@/components/settings/SignatureForm";
|
||||||
import { DangerZone } from "@/components/settings/DangerZone";
|
import { DangerZone } from "@/components/settings/DangerZone";
|
||||||
import { DemoToggle } from "@/components/demo/DemoToggle";
|
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")({
|
export const Route = createFileRoute("/_app/parametres")({
|
||||||
component: ParametresPage,
|
component: ParametresPage,
|
||||||
@ -24,6 +28,9 @@ export const Route = createFileRoute("/_app/parametres")({
|
|||||||
* du blast radius (modifier sa signature ne sauvegarde pas l'org, etc.).
|
* du blast radius (modifier sa signature ne sauvegarde pas l'org, etc.).
|
||||||
*/
|
*/
|
||||||
function ParametresPage() {
|
function ParametresPage() {
|
||||||
|
const { data: sub } = useSubscription();
|
||||||
|
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<header className="mb-4">
|
<header className="mb-4">
|
||||||
@ -64,6 +71,39 @@ function ParametresPage() {
|
|||||||
<SignatureForm />
|
<SignatureForm />
|
||||||
</SettingsSection>
|
</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
|
<SettingsSection
|
||||||
eyebrow="Démonstration"
|
eyebrow="Démonstration"
|
||||||
title={
|
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 |
|
| Hors-scope | Pourquoi |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Rubis Sur l'Ongle",
|
"name": "Rubis.",
|
||||||
"short_name": "Rubis",
|
"short_name": "Rubis",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -107,6 +107,9 @@ importers:
|
|||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
|
stripe:
|
||||||
|
specifier: ^22.1.1
|
||||||
|
version: 22.1.1(@types/node@25.6.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@adonisjs/assembler':
|
'@adonisjs/assembler':
|
||||||
specifier: ^8.4.0
|
specifier: ^8.4.0
|
||||||
@ -5617,6 +5620,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
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:
|
strnum@2.2.3:
|
||||||
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
||||||
|
|
||||||
@ -11644,6 +11656,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@22.1.1(@types/node@25.6.0):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.6.0
|
||||||
|
|
||||||
strnum@2.2.3: {}
|
strnum@2.2.3: {}
|
||||||
|
|
||||||
strtok3@10.3.5:
|
strtok3@10.3.5:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user