From 023f08c2617dd63bc6df405be83f06a922e997ea Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 17:36:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20commande=20ace=20`billing:scenario?= =?UTF-8?q?`=20pour=20tester=20les=20=C3=A9tats=20billing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --scenario 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 --- apps/api/commands/billing_scenario.ts | 305 ++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 apps/api/commands/billing_scenario.ts diff --git a/apps/api/commands/billing_scenario.ts b/apps/api/commands/billing_scenario.ts new file mode 100644 index 0000000..3cd7654 --- /dev/null +++ b/apps/api/commands/billing_scenario.ts @@ -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.') + } +} +