rubis/apps/api/app/services/billing.ts
ordinarthur f9cba50b5e
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m30s
Build & Deploy API / build-and-deploy (push) Successful in 1m43s
Build & Deploy Web / build-and-deploy (push) Successful in 33s
feat(billing,landing): plan Free 2 factures + scaffold preuves sociales/SEO
Suite des chantiers structurants de landing-optimisations.md.

#5 — Plan Free : 5 → 2 factures actives (cf. ADR-023)
  - PLAN_CAPS.free.activeInvoicesLimit dans apps/api/app/services/billing.ts
  - Tests unitaires alignés (4 → 1, 5 → 2 cap, delta 3 → delta 2)
  - billing:scenario command : commentaires + valeur par défaut
  - PlanLimitBanner : copy dynamique via {limit} au lieu de "5" hardcodé
  - /parametres/abonnement : H1 + tile Free (3 mois → 14 jours, 5 → 2)
  - billing.test.tsx (fixtures + cas test)
  - landing copy : hero feature pill, Pricing tile, FinalCTA, CGV §5
  - CLAUDE.md pricing table

#7 — Scaffold <TrustedBy /> (preuve sociale)
  - Composant qui render null tant que copy.trustedBy.{logos,testimonials}
    sont vides — pas de placeholder bidon.
  - Structure data dans copy.ts avec commentaires sur les prérequis
    avant d'ajouter une entrée (accord signé, photo, citation chiffrée).
  - Section insérée juste avant <Pricing /> (cf. doc §4).

#8 — Plan articles SEO + brouillon article 1
  - docs/marketing/seo-articles.md : 5 articles ciblés, mots-clés,
    structure type, lead magnet, calendrier 5 semaines.
  - Article 1 ("Modèle d'email de relance facture impayée") en
    brouillon complet, prêt à valider via l'admin blog (apps/api).

#6 — Plan détaillé migration Stripe trial 14 j (code reporté)
  - docs/tech/stripe-trial-with-card.md : état actuel vs cible,
    architecture (Stripe Checkout + trial_period_days), modifs DB
    (trial_ends_at), API (start-trial + webhook trial_will_end),
    SPA (onboarding/billing), 3 emails transactionnels avec contenu
    intégral, risques + mitigations, plan d'exécution 2,5 j.
  - Implémentation reportée à une session focus avec accès Stripe
    test mode (cartes 3DS, webhook signing secret).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:38:52 +02:00

192 lines
6.4 KiB
TypeScript

import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
/**
* Politique de plans Rubis V1 :
*
* - Free : 2 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 historique : les orgs créées avant le passage à 2
* factures bénéficient toujours d'un `gracePeriodEndsAt = createdAt +
* 3 mois` posé par la migration `1778157876956_alter_organizations_table`.
* Pendant cette fenêtre, AUCUNE limite n'est appliquée. Au-delà, si
* `activeInvoicesCount > limit` → import bloqué jusqu'à upgrade.
*
* Pour les nouvelles orgs, `gracePeriodEndsAt = null` par défaut : la
* limite Free s'applique immédiatement. L'essai 14 jours Pro (CB à
* l'inscription via Stripe Setup Intent) viendra remplacer cette grâce
* historique — cf. roadmap landing-optimisations.md §3-6.
*
* Choix Free 2 factures (vs 5 initialement) : cf. ADR-022. Le segment
* cœur (freelance/artisan/kiné) émet < 5 factures/mois et restait donc
* en Free perpétuel — 2 permet de tester sans rendre le produit utilisable
* en production solo.
*/
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: 2,
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
/**
* True si l'user a annulé sa souscription côté Stripe et qu'elle s'éteindra
* à `currentPeriodEnd`. Pendant cette fenêtre l'org reste sur son plan
* payant (status `active`), mais l'UI affiche "annulé, accès jusqu'au DD/MM"
* et propose un bouton "Réactiver".
*/
cancelAtPeriodEnd: 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,
cancelAtPeriodEnd: !!org.cancelAtPeriodEnd,
}
}