feat(api): commande ace billing:scenario pour tester les états billing
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Force un état billing sur l'org d'un user pour tester rapidement chaque comportement UI/enforcement sans passer par Stripe ni attendre 3 mois. Usage : node ace billing:scenario --email <user> --scenario <name> Scénarios : • status : ne touche rien, affiche juste l'état courant • fresh : reset signup neuf (free + grace 3 mois) • grace-expired : free, grace terminée, ≤ 5 actives → OK • limit-reached : free, grace terminée, force 5 actives → bloqué (402) • pro : pro mensuel actif, fake IDs si pas de vrais • pro-cancelling : pro + cancel_at_period_end=true → bandeau ANNULÉ • pro-past-due : pro + status=past_due → warning UI • business : business mensuel actif Sécurité : préserve les VRAIS Stripe IDs s'ils existent (= l'org a déjà payé). Génère des fake `cus_test_FAKE_*` / `sub_test_FAKE_*` seulement si NULL — ne pas écraser une vraie souscription. Le command affiche un récap compact à chaque exécution : - plan / grace / Stripe IDs / status / cancel_at - factures actives vs limite - création autorisée ou non + raison Pour tester un comportement côté UI : 1. Lance le scénario 2. Reload /parametres/abonnement et /factures 3. Vérifie le rendu (bandeau cancel, blocage import, etc.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3bad1451a9
commit
023f08c261
305
apps/api/commands/billing_scenario.ts
Normal file
305
apps/api/commands/billing_scenario.ts
Normal file
@ -0,0 +1,305 @@
|
||||
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 3 mois)
|
||||
* - grace-expired : free, grace terminée, ≤ 5 factures actives → OK
|
||||
* - limit-reached : free, grace terminée, 5 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 3 mois 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 ≤ 5 : OK. Si > 5 : bloqué.'
|
||||
)
|
||||
}
|
||||
|
||||
private async applyLimitReached(org: Organization) {
|
||||
await this.applyGraceExpired(org)
|
||||
// S'assurer qu'il y a au moins 5 factures actives. Si moins, on en crée
|
||||
// de quoi passer à 5 minimum.
|
||||
const current = await countActiveInvoices(org.id)
|
||||
const limit = PLAN_CAPS.free.activeInvoicesLimit ?? 5
|
||||
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.')
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user