rubis/apps/api/tests/unit/billing.spec.ts
ordinarthur f9cba50b5e
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m30s
Build & Deploy API / build-and-deploy (push) Successful in 1m43s
Build & Deploy Web / build-and-deploy (push) Successful in 33s
feat(billing,landing): plan Free 2 factures + scaffold preuves sociales/SEO
Suite des chantiers structurants de landing-optimisations.md.

#5 — Plan Free : 5 → 2 factures actives (cf. ADR-023)
  - PLAN_CAPS.free.activeInvoicesLimit dans apps/api/app/services/billing.ts
  - Tests unitaires alignés (4 → 1, 5 → 2 cap, delta 3 → delta 2)
  - billing:scenario command : commentaires + valeur par défaut
  - PlanLimitBanner : copy dynamique via {limit} au lieu de "5" hardcodé
  - /parametres/abonnement : H1 + tile Free (3 mois → 14 jours, 5 → 2)
  - billing.test.tsx (fixtures + cas test)
  - landing copy : hero feature pill, Pricing tile, FinalCTA, CGV §5
  - CLAUDE.md pricing table

#7 — Scaffold <TrustedBy /> (preuve sociale)
  - Composant qui render null tant que copy.trustedBy.{logos,testimonials}
    sont vides — pas de placeholder bidon.
  - Structure data dans copy.ts avec commentaires sur les prérequis
    avant d'ajouter une entrée (accord signé, photo, citation chiffrée).
  - Section insérée juste avant <Pricing /> (cf. doc §4).

#8 — Plan articles SEO + brouillon article 1
  - docs/marketing/seo-articles.md : 5 articles ciblés, mots-clés,
    structure type, lead magnet, calendrier 5 semaines.
  - Article 1 ("Modèle d'email de relance facture impayée") en
    brouillon complet, prêt à valider via l'admin blog (apps/api).

#6 — Plan détaillé migration Stripe trial 14 j (code reporté)
  - docs/tech/stripe-trial-with-card.md : état actuel vs cible,
    architecture (Stripe Checkout + trial_period_days), modifs DB
    (trial_ends_at), API (start-trial + webhook trial_will_end),
    SPA (onboarding/billing), 3 emails transactionnels avec contenu
    intégral, risques + mitigations, plan d'exécution 2,5 j.
  - Implémentation reportée à une session focus avec accès Stripe
    test mode (cartes 3DS, webhook signing secret).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:38:52 +02:00

275 lines
10 KiB
TypeScript

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<Client> {
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<Invoice> {
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 : 2 factures max, 1 user, pas de multi-users', ({ assert }) => {
assert.equal(PLAN_CAPS.free.activeInvoicesLimit, 2)
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 + 1 active → on peut en ajouter 1 (1+1 ≤ 2)', async ({ assert }) => {
const { org } = await createTestUser()
org.plan = 'free'
org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 })
await org.save()
const client = await makeClientFor(org)
await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 1)
assert.isTrue(result.allowed)
})
test('Free post-grace + 2 actives → bloqué (2+1 > 2)', 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 < 2; 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, 2)
assert.equal(result.current, 2)
}
})
test('Free post-grace + 1 active + delta=2 → bloqué (1+2 > 2)', async ({ assert }) => {
const { org } = await createTestUser()
org.plan = 'free'
org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 })
await org.save()
const client = await makeClientFor(org)
await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 2)
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)
// 10 paid + 0 actives = encore 2 slots dispos
for (let i = 0; i < 10; i++) await makeInvoice(org, client, 'paid')
const result = await canCreateInvoices(org.id, 2)
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, 2)
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)
})
})