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>
173 lines
5.5 KiB
TypeScript
173 lines
5.5 KiB
TypeScript
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,
|
|
}
|
|
}
|