rubis/apps/api/commands/billing_scenario.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

306 lines
10 KiB
TypeScript

import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { DateTime } from 'luxon'
import User from '#models/user'
import Organization from '#models/organization'
import Client from '#models/client'
import Invoice from '#models/invoice'
import { canCreateInvoices, countActiveInvoices, PLAN_CAPS } from '#services/billing'
/**
* Force un état billing sur l'org d'un user pour tester rapidement chaque
* comportement de l'UI/enforcement sans passer par Stripe ni attendre 3 mois.
*
* Usage :
*
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario status
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario fresh
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario grace-expired
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario limit-reached
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario pro
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario pro-cancelling
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario pro-past-due
* node ace billing:scenario --email arthurbarre.js@gmail.com --scenario business
*
* Scénarios :
* - status : ne touche rien, affiche juste l'état courant
* - fresh : reset à un signup neuf (free + grace 14 jours)
* - grace-expired : free, grace terminée, ≤ 2 factures actives → OK
* - limit-reached : free, grace terminée, 2 factures actives forcées → bloqué
* - pro : pro mensuel actif, fake Stripe IDs
* - pro-cancelling : pro mais annulation programmée à period_end
* - pro-past-due : pro mais paiement échoué (status past_due)
* - business : business mensuel actif
*/
const SCENARIOS = [
'status',
'fresh',
'grace-expired',
'limit-reached',
'pro',
'pro-cancelling',
'pro-past-due',
'business',
] as const
type Scenario = (typeof SCENARIOS)[number]
export default class BillingScenario extends BaseCommand {
static commandName = 'billing:scenario'
static description =
"Force un état billing sur un user pour tester les comportements UI/enforcement"
static options: CommandOptions = {
startApp: true,
}
@flags.string({
description: "Email du user à modifier",
required: true,
})
declare email: string
@flags.string({
description: `Scénario : ${SCENARIOS.join(' | ')}`,
required: true,
})
declare scenario: string
async run() {
const email = String(this.email).toLowerCase()
const user = await User.findBy('email', email)
if (!user || !user.organizationId) {
this.logger.error(`User ${email} introuvable ou sans organisation`)
this.exitCode = 1
return
}
const org = await Organization.findOrFail(user.organizationId)
if (!SCENARIOS.includes(this.scenario as Scenario)) {
this.logger.error(
`Scénario inconnu "${this.scenario}". Valides : ${SCENARIOS.join(', ')}`
)
this.exitCode = 1
return
}
switch (this.scenario as Scenario) {
case 'status':
break
case 'fresh':
await this.applyFresh(org)
break
case 'grace-expired':
await this.applyGraceExpired(org)
break
case 'limit-reached':
await this.applyLimitReached(org)
break
case 'pro':
await this.applyPro(org)
break
case 'pro-cancelling':
await this.applyProCancelling(org)
break
case 'pro-past-due':
await this.applyProPastDue(org)
break
case 'business':
await this.applyBusiness(org)
break
}
await org.refresh()
await this.printStatus(org)
}
// ---------------------------------------------------------------------------
// Scénarios
// ---------------------------------------------------------------------------
private async applyFresh(org: Organization) {
org.plan = 'free'
org.gracePeriodEndsAt = DateTime.utc().plus({ months: 3 })
org.stripeCustomerId = null
org.stripeSubscriptionId = null
org.subscriptionStatus = null
org.billingCycle = null
org.currentPeriodEnd = null
org.cancelAtPeriodEnd = false
await org.save()
this.logger.info('→ Free, grace period 14 jours fresh, aucun Stripe customer')
}
private async applyGraceExpired(org: Organization) {
org.plan = 'free'
org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 })
org.stripeCustomerId = null
org.stripeSubscriptionId = null
org.subscriptionStatus = null
org.billingCycle = null
org.currentPeriodEnd = null
org.cancelAtPeriodEnd = false
await org.save()
this.logger.info(
'→ Free, grace period expirée. Si actives ≤ 2 : OK. Si > 2 : bloqué.'
)
}
private async applyLimitReached(org: Organization) {
await this.applyGraceExpired(org)
// S'assurer qu'il y a au moins `limit` factures actives. Si moins, on en
// crée pour atteindre la limite et déclencher le blocage import.
const current = await countActiveInvoices(org.id)
const limit = PLAN_CAPS.free.activeInvoicesLimit ?? 2
if (current >= limit) {
this.logger.info(`→ Déjà ${current} factures actives (≥ ${limit}), OK`)
return
}
const need = limit - current
this.logger.info(`→ Création de ${need} factures dummy pour atteindre la limite`)
await this.createDummyInvoices(org, need)
}
private async applyPro(org: Organization) {
org.plan = 'pro'
org.gracePeriodEndsAt = null
// Préserve les VRAIS Stripe IDs si l'org en a déjà (= a déjà payé).
// Sinon, fake IDs pour que l'UI affiche le bouton "Manage" etc.
org.stripeCustomerId =
org.stripeCustomerId ?? `cus_test_FAKE_${org.id.slice(0, 8)}`
org.stripeSubscriptionId =
org.stripeSubscriptionId ?? `sub_test_FAKE_${org.id.slice(0, 8)}`
org.subscriptionStatus = 'active'
org.billingCycle = 'monthly'
org.currentPeriodEnd = DateTime.utc().plus({ days: 28 })
org.cancelAtPeriodEnd = false
await org.save()
this.logger.info('→ Pro mensuel actif, period_end dans 28 jours')
}
private async applyProCancelling(org: Organization) {
await this.applyPro(org)
org.cancelAtPeriodEnd = true
await org.save()
this.logger.info('→ Pro mais annulation programmée (cancel_at_period_end=true)')
}
private async applyProPastDue(org: Organization) {
await this.applyPro(org)
org.subscriptionStatus = 'past_due'
await org.save()
this.logger.info('→ Pro mais paiement échoué (status=past_due)')
}
private async applyBusiness(org: Organization) {
org.plan = 'business'
org.gracePeriodEndsAt = null
// Préserve les vrais Stripe IDs s'ils existent (cf. applyPro).
org.stripeCustomerId =
org.stripeCustomerId ?? `cus_test_FAKE_${org.id.slice(0, 8)}`
org.stripeSubscriptionId =
org.stripeSubscriptionId ?? `sub_test_FAKE_${org.id.slice(0, 8)}`
org.subscriptionStatus = 'active'
org.billingCycle = 'monthly'
org.currentPeriodEnd = DateTime.utc().plus({ days: 28 })
org.cancelAtPeriodEnd = false
await org.save()
this.logger.info('→ Business mensuel actif')
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Crée des factures dummy en `pending` (= active) pour gonfler artificiellement
* le compteur. Si l'org n'a pas de client, on en crée un aussi.
*/
private async createDummyInvoices(org: Organization, count: number) {
let client = await Client.query()
.where('organization_id', org.id)
.first()
if (!client) {
client = await Client.create({
organizationId: org.id,
name: 'Client Test Billing',
email: 'billing-test@example.com',
contactFirstName: null,
contactLastName: null,
phone: null,
address: null,
siret: null,
notes: null,
})
}
const now = DateTime.utc()
for (let i = 0; i < count; i++) {
await Invoice.create({
organizationId: org.id,
clientId: client.id,
planId: null,
numero: `BILLING-TEST-${Date.now()}-${i}`,
amountTtcCents: 50_000,
issueDate: now.minus({ days: 5 }),
dueDate: now.plus({ days: 25 }),
paidAt: null,
status: 'pending',
pdfStorageKey: null,
rubisEarned: 0,
notes: null,
})
}
}
/** Affiche l'état courant d'une façon lisible. */
private async printStatus(org: Organization) {
const activeCount = await countActiveInvoices(org.id)
const enforcement = await canCreateInvoices(org.id, 1)
const caps = PLAN_CAPS[(org.plan ?? 'free') as keyof typeof PLAN_CAPS]
const inGrace = !!(org.gracePeriodEndsAt && org.gracePeriodEndsAt > DateTime.utc())
this.logger.info('')
this.logger.info('────────── ÉTAT BILLING ──────────')
this.logger.info(`Org : ${org.id} "${org.name}"`)
this.logger.info(`Plan : ${org.plan}`)
this.logger.info(
`Grace period : ${
org.gracePeriodEndsAt
? `${org.gracePeriodEndsAt.toFormat('yyyy-LL-dd')} (${inGrace ? 'EN COURS' : 'TERMINÉE'})`
: 'NONE'
}`
)
this.logger.info(`Stripe customer : ${org.stripeCustomerId ?? '—'}`)
this.logger.info(`Subscription : ${org.stripeSubscriptionId ?? '—'}`)
this.logger.info(
`Status : ${org.subscriptionStatus ?? '—'} (${org.billingCycle ?? 'no cycle'})`
)
this.logger.info(
`Period end : ${org.currentPeriodEnd?.toFormat('yyyy-LL-dd') ?? '—'}`
)
this.logger.info(`Cancel scheduled : ${org.cancelAtPeriodEnd ? '✓ OUI' : '✗ non'}`)
this.logger.info('')
this.logger.info(
`Factures actives : ${activeCount} / ${
caps.activeInvoicesLimit ?? '∞'
}`
)
this.logger.info(
`Création autorisée (delta=1) : ${
enforcement.allowed ? '✓ YES' : '✗ BLOQUÉ'
}`
)
if (!enforcement.allowed) {
this.logger.info(
` Raison : ${enforcement.reason}, limite=${enforcement.limit}, courant=${enforcement.current}`
)
}
this.logger.info('───────────────────────────────────')
this.logger.info('')
this.logger.info('Reload /parametres/abonnement et /factures côté SPA pour voir.')
}
}