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.') } }