Implémente le chantier #6 de docs/tech/landing-optimisations.md. Le funnel signup propose maintenant un essai 14 j Pro avec carte demandée mais non prélevée — prélèvement automatique à J+14 avec rappel à J+11 (webhook customer.subscription.trial_will_end de Stripe). Couverture tests : 60 tests unitaires sur la couche billing - billing.spec.ts (25) — quota Free, bypass trial, inTrial state - stripe_billing.spec.ts (24) — handlers webhook, idempotence, dispatcher - trial_recap_job.spec.ts (11) — stats aggregation, formatRubisToHoursFr + 3 nouveaux tests vitest côté SPA (useTrialDaysRemaining, useIsAtFreeLimit bypass trial). Backend : - Migration 1779000000000_add_trial_ends_at_to_organizations - PLAN_CAPS bypass quand status=trialing AND trial_ends_at futur - getOrgSubscriptionState expose inTrial + trialEndsAt - Refactor handlers webhook en service stripe_billing.ts (pures, testables) — extraction depuis le controller. dispatchWebhookEvent routeur typé également extrait pour les tests. - createTrialCheckoutSession avec subscription_data.trial_period_days=14, garde-fou TrialAlreadyConsumedError contre re-trial. - handleTrialWillEnd → enqueue job recap (BullMQ jobId déterministe basé sur subscriptionId, idempotent contre re-delivery Stripe). - Endpoint POST /api/v1/billing/start-trial. - Email template trial_recap (React Email, branding Rubis figé) avec stats: factures importées, relances envoyées, € récupérés, rubis + heures libérées. Infra de test : - tests/helpers/stripe_mock.ts : __setStripeForTests injection + factories fakeSubscription / fakeCheckoutSession / fakeInvoice. - __setTrialRecapEnqueueForTests : permet de spy l'enqueue sans Redis. Frontend : - /onboarding/billing.tsx (opt-in, pas encore forcé dans le flow) : bouton primaire essai 14j + fallback "Free 2 factures". - PlanLimitBanner : nouveau état "Essai Pro · X jours restants" qui prime sur les autres bandeaux. Discret rubis-glow, non blocant. - useStartTrial hook + useTrialDaysRemaining (arrondi sup). - SubscriptionState typé avec inTrial + trialEndsAt. Landing : - Sous-texte CTA réactivé : « CB demandée, non prélevée avant J+14 » (Hero + FinalCTA), maintenant promesse véridique. Notes ouvertes (à décider ultérieurement) : - Tunnel /onboarding/billing FORCÉ entre signup et /onboarding/compte : guard reste à activer (risque cassage du signup actuel sinon). Pour l'instant l'écran est accessible mais opt-in. - Cron de redondance trial-recap : pas encore implémenté (le jobId déterministe BullMQ couvre déjà la double-livraison Stripe). À ajouter si on observe des trial sans recap en prod. - Tests E2E avec Stripe test mode à faire avant le go-live (cartes 3DS 4000 0027 6000 3184, declined 4000 0000 0000 0341). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
420 lines
16 KiB
TypeScript
420 lines
16 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)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trial 14 j — bypass quota Free, inTrial state, trial_end persisté
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.group('billing — essai 14 j (trial bypass + état)', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('Free + status=trialing + trial_ends_at futur → canCreateInvoices unlimited', async ({
|
|
assert,
|
|
}) => {
|
|
// Org en essai Pro 14 j : pas encore Pro côté `plan` (Stripe ne flip
|
|
// qu'au prélèvement à J+14), mais `subscriptionStatus=trialing`
|
|
// signale l'essai actif. On veut quand même autoriser un usage
|
|
// illimité — l'user a donné sa CB, il a accès Pro complet.
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.gracePeriodEndsAt = null
|
|
org.subscriptionStatus = 'trialing'
|
|
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
|
|
await org.save()
|
|
const client = await makeClientFor(org)
|
|
for (let i = 0; i < 50; i++) await makeInvoice(org, client, 'pending')
|
|
const result = await canCreateInvoices(org.id, 5)
|
|
assert.isTrue(result.allowed)
|
|
})
|
|
|
|
test('Free + status=trialing mais trial_ends_at passé → quota Free s\'applique', async ({
|
|
assert,
|
|
}) => {
|
|
// Garde-fou : si le webhook trial→active a été manqué et que
|
|
// `trial_ends_at` est dans le passé, on retombe sur le cap Free
|
|
// pour ne pas laisser un illimité à vie.
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.gracePeriodEndsAt = null
|
|
org.subscriptionStatus = 'trialing'
|
|
org.trialEndsAt = DateTime.utc().minus({ days: 1 })
|
|
await org.save()
|
|
const client = await makeClientFor(org)
|
|
for (let i = 0; i < 2; i++) await makeInvoice(org, client, 'pending')
|
|
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)
|
|
}
|
|
})
|
|
|
|
test('Free + trial_ends_at futur mais status=active → pas en trial → quota s\'applique', async ({
|
|
assert,
|
|
}) => {
|
|
// Vérifie l'AND logique : il faut les DEUX conditions (status trialing
|
|
// ET trial_ends_at futur). Si l'un manque, pas de bypass.
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.gracePeriodEndsAt = null
|
|
org.subscriptionStatus = 'active' // pas trialing !
|
|
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
|
|
await org.save()
|
|
const client = await makeClientFor(org)
|
|
for (let i = 0; i < 2; i++) await makeInvoice(org, client, 'pending')
|
|
const result = await canCreateInvoices(org.id, 1)
|
|
assert.isFalse(result.allowed)
|
|
})
|
|
|
|
test('Trial déjà consommé + redescente en Free → trial_ends_at conservé pour historique', async ({
|
|
assert,
|
|
}) => {
|
|
// Sémantique : `trial_ends_at` est posé une fois, jamais effacé. Sert
|
|
// à empêcher de relancer un trial après annulation.
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.subscriptionStatus = 'canceled'
|
|
org.trialEndsAt = DateTime.utc().minus({ days: 5 })
|
|
await org.save()
|
|
// Le quota Free s'applique normalement (pas de bypass car status ≠
|
|
// trialing).
|
|
const client = await makeClientFor(org)
|
|
for (let i = 0; i < 2; i++) await makeInvoice(org, client, 'pending')
|
|
const result = await canCreateInvoices(org.id, 1)
|
|
assert.isFalse(result.allowed)
|
|
})
|
|
|
|
test('getOrgSubscriptionState expose inTrial + trialEndsAt ISO', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
const trialEnd = DateTime.utc().plus({ days: 12 }).startOf('second')
|
|
org.plan = 'free'
|
|
org.gracePeriodEndsAt = null
|
|
org.subscriptionStatus = 'trialing'
|
|
org.trialEndsAt = trialEnd
|
|
await org.save()
|
|
|
|
const state = await getOrgSubscriptionState(org.id)
|
|
assert.isTrue(state.inTrial)
|
|
assert.isNotNull(state.trialEndsAt)
|
|
// Comparaison ISO côté second — Postgres tronque les microsecondes
|
|
// côté écriture.
|
|
const expected = trialEnd.toISO()
|
|
assert.isNotNull(expected)
|
|
assert.equal(
|
|
DateTime.fromISO(state.trialEndsAt!).toUnixInteger(),
|
|
trialEnd.toUnixInteger()
|
|
)
|
|
})
|
|
|
|
test('getOrgSubscriptionState : inTrial=false si pas en essai (sub status null)', async ({
|
|
assert,
|
|
}) => {
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.subscriptionStatus = null
|
|
org.trialEndsAt = null
|
|
await org.save()
|
|
const state = await getOrgSubscriptionState(org.id)
|
|
assert.isFalse(state.inTrial)
|
|
assert.isNull(state.trialEndsAt)
|
|
})
|
|
|
|
test('getOrgSubscriptionState : inTrial=false si trial_ends_at passé', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.subscriptionStatus = 'trialing'
|
|
org.trialEndsAt = DateTime.utc().minus({ minutes: 1 })
|
|
await org.save()
|
|
const state = await getOrgSubscriptionState(org.id)
|
|
assert.isFalse(state.inTrial)
|
|
})
|
|
|
|
test('Grace period prime sur trial pour le bypass canCreateInvoices', async ({ assert }) => {
|
|
// Cas : une org historique avec grace 3 mois entame *aussi* un trial.
|
|
// Les deux conditions autorisent le bypass — on vérifie juste que
|
|
// l'usage illimité est bien accordé sans regarder lequel a primé.
|
|
const { org } = await createTestUser()
|
|
org.plan = 'free'
|
|
org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 })
|
|
org.subscriptionStatus = 'trialing'
|
|
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
|
|
await org.save()
|
|
const client = await makeClientFor(org)
|
|
for (let i = 0; i < 20; i++) await makeInvoice(org, client, 'pending')
|
|
const result = await canCreateInvoices(org.id, 1)
|
|
assert.isTrue(result.allowed)
|
|
})
|
|
})
|