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>
306 lines
10 KiB
TypeScript
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 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.')
|
|
}
|
|
}
|
|
|