import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import { DateTime } from 'luxon' import { PLAN_CAPS, canCreateInvoices, countActiveInvoices, getOrgSubscriptionState, } from '#services/billing' import Organization from '#models/organization' import Client from '#models/client' import Invoice from '#models/invoice' import { createTestUser } from '../helpers/auth.js' /** * Tests unitaires sur la logique de plans + enforcement. * On utilise une DB transaction par test (auto-rollback) pour isoler chaque * scénario sans pollution croisée. Pas de mock de DB — on teste la vraie * logique SQL `whereIn(status, ACTIVE_STATUSES)`. */ const ACTIVE_STATUSES = ['pending', 'awaiting_user_confirmation', 'in_relance', 'litigation'] as const const INACTIVE_STATUSES = ['paid', 'cancelled'] as const async function makeClientFor(org: Organization): Promise { return Client.create({ organizationId: org.id, name: `Client ${Math.random().toString(36).slice(2, 8)}`, email: `client-${Math.random().toString(36).slice(2, 8)}@spec.test`, contactFirstName: null, contactLastName: null, phone: null, address: null, siret: null, notes: null, }) } async function makeInvoice( org: Organization, client: Client, status: Invoice['status'] ): Promise { const issue = DateTime.utc().minus({ days: 30 }) return Invoice.create({ organizationId: org.id, clientId: client.id, planId: null, numero: `F-${Math.random().toString(36).slice(2, 10)}`, amountTtcCents: 100_00, issueDate: issue, dueDate: issue.plus({ days: 30 }), paidAt: status === 'paid' ? DateTime.utc() : null, status, pdfStorageKey: null, rubisEarned: 0, notes: null, }) } // --------------------------------------------------------------------------- // PLAN_CAPS — sanity check sur les caps de chaque plan // --------------------------------------------------------------------------- test.group('billing — PLAN_CAPS', () => { test('Free : 5 factures max, 1 user, pas de multi-users', ({ assert }) => { assert.equal(PLAN_CAPS.free.activeInvoicesLimit, 5) assert.equal(PLAN_CAPS.free.seatsLimit, 1) assert.isFalse(PLAN_CAPS.free.multiUsers) assert.isFalse(PLAN_CAPS.free.replyFromUserEmail) }) test('Pro : factures illimitées, 1 user', ({ assert }) => { assert.isNull(PLAN_CAPS.pro.activeInvoicesLimit) assert.equal(PLAN_CAPS.pro.seatsLimit, 1) assert.isFalse(PLAN_CAPS.pro.multiUsers) }) test('Business : illimité + 5 sièges + reply-from-user', ({ assert }) => { assert.isNull(PLAN_CAPS.business.activeInvoicesLimit) assert.equal(PLAN_CAPS.business.seatsLimit, 5) assert.isTrue(PLAN_CAPS.business.multiUsers) assert.isTrue(PLAN_CAPS.business.replyFromUserEmail) }) }) // --------------------------------------------------------------------------- // countActiveInvoices — compte les statuts pending/awaiting/in_relance/litigation // --------------------------------------------------------------------------- test.group('billing — countActiveInvoices', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('compte les 4 statuts actifs', async ({ assert }) => { const { org } = await createTestUser() const client = await makeClientFor(org) for (const status of ACTIVE_STATUSES) { await makeInvoice(org, client, status) } const n = await countActiveInvoices(org.id) assert.equal(n, ACTIVE_STATUSES.length) }) test('exclut les statuts inactifs (paid, cancelled)', async ({ assert }) => { const { org } = await createTestUser() const client = await makeClientFor(org) for (const status of INACTIVE_STATUSES) { await makeInvoice(org, client, status) } const n = await countActiveInvoices(org.id) assert.equal(n, 0) }) test('isolation par org : ne compte pas les factures d\'une autre org', async ({ assert }) => { const { org: orgA } = await createTestUser() const { org: orgB } = await createTestUser() const clientB = await makeClientFor(orgB) await makeInvoice(orgB, clientB, 'pending') await makeInvoice(orgB, clientB, 'in_relance') const n = await countActiveInvoices(orgA.id) assert.equal(n, 0) }) }) // --------------------------------------------------------------------------- // canCreateInvoices — règle d'enforcement principale // --------------------------------------------------------------------------- test.group('billing — canCreateInvoices', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('Free + grace period active → autorisé sans limite', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 }) await org.save() const client = await makeClientFor(org) // 50 factures actives, on devrait quand même pouvoir en ajouter for (let i = 0; i < 50; i++) await makeInvoice(org, client, 'pending') const result = await canCreateInvoices(org.id, 1) assert.isTrue(result.allowed) }) test('Free post-grace + 4 actives → on peut en ajouter 1 (4+1 ≤ 5)', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) await org.save() const client = await makeClientFor(org) for (let i = 0; i < 4; i++) await makeInvoice(org, client, 'pending') const result = await canCreateInvoices(org.id, 1) assert.isTrue(result.allowed) }) test('Free post-grace + 5 actives → bloqué (5+1 > 5)', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) await org.save() const client = await makeClientFor(org) for (let i = 0; i < 5; i++) await makeInvoice(org, client, 'in_relance') const result = await canCreateInvoices(org.id, 1) assert.isFalse(result.allowed) if (!result.allowed) { assert.equal(result.reason, 'free_limit_active_invoices') assert.equal(result.limit, 5) assert.equal(result.current, 5) } }) test('Free post-grace + 3 actives + delta=3 → bloqué (3+3 > 5)', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) await org.save() const client = await makeClientFor(org) for (let i = 0; i < 3; i++) await makeInvoice(org, client, 'pending') const result = await canCreateInvoices(org.id, 3) assert.isFalse(result.allowed) }) test('Pro → toujours autorisé peu importe le compteur', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'pro' org.gracePeriodEndsAt = DateTime.utc().minus({ months: 6 }) await org.save() const client = await makeClientFor(org) for (let i = 0; i < 200; i++) await makeInvoice(org, client, 'in_relance') const result = await canCreateInvoices(org.id, 1) assert.isTrue(result.allowed) }) test('Business → toujours autorisé', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'business' await org.save() const client = await makeClientFor(org) for (let i = 0; i < 100; i++) await makeInvoice(org, client, 'in_relance') const result = await canCreateInvoices(org.id, 1) assert.isTrue(result.allowed) }) test('Free post-grace : factures paid ne consomment pas de slot', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) await org.save() const client = await makeClientFor(org) // 5 paid + 0 actives = encore 5 slots dispos for (let i = 0; i < 5; i++) await makeInvoice(org, client, 'paid') const result = await canCreateInvoices(org.id, 5) assert.isTrue(result.allowed) }) }) // --------------------------------------------------------------------------- // getOrgSubscriptionState — shape & cohérence // --------------------------------------------------------------------------- test.group('billing — getOrgSubscriptionState', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('Free + grace active → inGracePeriod=true', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 }) await org.save() const state = await getOrgSubscriptionState(org.id) assert.equal(state.plan, 'free') assert.isTrue(state.inGracePeriod) assert.equal(state.activeInvoicesCount, 0) assert.equal(state.caps.activeInvoicesLimit, 5) assert.isFalse(state.hasStripeCustomer) }) test('Free post-grace → inGracePeriod=false', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'free' org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) await org.save() const state = await getOrgSubscriptionState(org.id) assert.isFalse(state.inGracePeriod) }) test('Pro avec subscription → reflète status et period_end Stripe', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'pro' org.subscriptionStatus = 'active' org.billingCycle = 'monthly' org.currentPeriodEnd = DateTime.utc().plus({ days: 23 }) org.stripeCustomerId = 'cus_test_123' await org.save() const state = await getOrgSubscriptionState(org.id) assert.equal(state.plan, 'pro') assert.equal(state.subscriptionStatus, 'active') assert.equal(state.billingCycle, 'monthly') assert.isNotNull(state.currentPeriodEnd) assert.isTrue(state.hasStripeCustomer) assert.isFalse(state.inGracePeriod) // les payants ne sont jamais "en grâce" }) test('Compteur actif inclut bien les 4 statuts', async ({ assert }) => { const { org } = await createTestUser() const client = await makeClientFor(org) for (const status of ACTIVE_STATUSES) { await makeInvoice(org, client, status) } await makeInvoice(org, client, 'paid') await makeInvoice(org, client, 'cancelled') const state = await getOrgSubscriptionState(org.id) assert.equal(state.activeInvoicesCount, ACTIVE_STATUSES.length) }) })