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>
482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
import { test } from '@japa/runner'
|
|
import testUtils from '@adonisjs/core/services/test_utils'
|
|
import { DateTime } from 'luxon'
|
|
|
|
import {
|
|
applySubscriptionToOrg,
|
|
createTrialCheckoutSession,
|
|
handleCheckoutCompleted,
|
|
handlePaymentFailed,
|
|
handleSubscriptionDeleted,
|
|
handleSubscriptionUpdate,
|
|
handleTrialWillEnd,
|
|
TrialAlreadyConsumedError,
|
|
__setTrialRecapEnqueueForTests,
|
|
} from '#services/stripe_billing'
|
|
import { dispatchWebhookEvent } from '#controllers/billing_controller'
|
|
import Organization from '#models/organization'
|
|
|
|
import { createTestUser } from '../helpers/auth.js'
|
|
import {
|
|
fakeCheckoutSession,
|
|
fakeInvoice,
|
|
fakeSubscription,
|
|
installStripeMock,
|
|
uninstallStripeMock,
|
|
} from '../helpers/stripe_mock.js'
|
|
|
|
/**
|
|
* Tests des handlers webhook + helper trial du service `stripe_billing`.
|
|
*
|
|
* Stratégie de mocking : on injecte un client Stripe partiel via
|
|
* `installStripeMock()` (cf. helpers). Tous les appels SDK (retrieve,
|
|
* sessions.create, billingPortal.create) sont stubés.
|
|
*
|
|
* Toutes les assertions DB tournent dans une transaction `withGlobalTransaction`
|
|
* (auto-rollback per test) pour isoler les modifs.
|
|
*/
|
|
|
|
test.group('stripe_billing — applySubscriptionToOrg', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('persiste plan, cycle, status, trial_end depuis une subscription Stripe', async ({
|
|
assert,
|
|
}) => {
|
|
const { org } = await createTestUser()
|
|
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
|
|
const periodEndEpoch = trialEndEpoch + 30 * 24 * 3600
|
|
|
|
const sub = fakeSubscription({
|
|
id: 'sub_test_apply',
|
|
customerId: 'cus_test_apply',
|
|
status: 'trialing',
|
|
lookupKey: 'rubis_pro_monthly',
|
|
currentPeriodEnd: periodEndEpoch,
|
|
trialEnd: trialEndEpoch,
|
|
})
|
|
|
|
await applySubscriptionToOrg(org.id, sub)
|
|
await org.refresh()
|
|
|
|
assert.equal(org.plan, 'pro')
|
|
assert.equal(org.subscriptionStatus, 'trialing')
|
|
assert.equal(org.billingCycle, 'monthly')
|
|
assert.equal(org.stripeSubscriptionId, 'sub_test_apply')
|
|
assert.isNotNull(org.trialEndsAt)
|
|
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
|
|
assert.equal(org.currentPeriodEnd?.toUnixInteger(), periodEndEpoch)
|
|
assert.isFalse(org.cancelAtPeriodEnd)
|
|
})
|
|
|
|
test('détecte cancel_at_period_end (true) ET cancel_at (timestamp)', async ({ assert }) => {
|
|
const { org: orgA } = await createTestUser()
|
|
const { org: orgB } = await createTestUser()
|
|
|
|
// Cas A : cancel_at_period_end = true (API directe)
|
|
await applySubscriptionToOrg(
|
|
orgA.id,
|
|
fakeSubscription({
|
|
id: 'sub_cancel_a',
|
|
lookupKey: 'rubis_pro_monthly',
|
|
cancelAtPeriodEnd: true,
|
|
})
|
|
)
|
|
await orgA.refresh()
|
|
assert.isTrue(orgA.cancelAtPeriodEnd)
|
|
|
|
// Cas B : cancel_at = timestamp (Customer Portal)
|
|
await applySubscriptionToOrg(
|
|
orgB.id,
|
|
fakeSubscription({
|
|
id: 'sub_cancel_b',
|
|
lookupKey: 'rubis_pro_monthly',
|
|
cancelAt: Math.floor(Date.now() / 1000) + 86_400,
|
|
})
|
|
)
|
|
await orgB.refresh()
|
|
assert.isTrue(orgB.cancelAtPeriodEnd)
|
|
})
|
|
|
|
test('idempotent : 2 appels successifs → même état final', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
const sub = fakeSubscription({
|
|
id: 'sub_test_idem',
|
|
status: 'active',
|
|
lookupKey: 'rubis_business_yearly',
|
|
})
|
|
|
|
await applySubscriptionToOrg(org.id, sub)
|
|
const firstState = (await Organization.findOrFail(org.id)).$attributes
|
|
await applySubscriptionToOrg(org.id, sub)
|
|
const secondState = (await Organization.findOrFail(org.id)).$attributes
|
|
|
|
assert.equal(firstState['plan'], 'business')
|
|
assert.equal(firstState['billingCycle'], 'yearly')
|
|
assert.equal(firstState['subscriptionStatus'], secondState['subscriptionStatus'])
|
|
assert.equal(firstState['stripeSubscriptionId'], secondState['stripeSubscriptionId'])
|
|
})
|
|
|
|
test('org introuvable → no-op silencieux (pas de throw)', async ({ assert }) => {
|
|
// L'UUID non existant ne doit pas casser le webhook (Stripe retry sinon).
|
|
await assert.doesNotReject(() =>
|
|
applySubscriptionToOrg(
|
|
'00000000-0000-0000-0000-000000000000',
|
|
fakeSubscription({ lookupKey: 'rubis_pro_monthly' })
|
|
)
|
|
)
|
|
})
|
|
|
|
test('lookup_key inconnu → plan retombe sur free', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
await applySubscriptionToOrg(
|
|
org.id,
|
|
fakeSubscription({ lookupKey: 'unknown_key_xyz' })
|
|
)
|
|
await org.refresh()
|
|
assert.equal(org.plan, 'free')
|
|
assert.isNull(org.billingCycle)
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — handleSubscriptionUpdate (lookup org)', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('lookup via metadata.organization_id', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
await handleSubscriptionUpdate(
|
|
fakeSubscription({
|
|
lookupKey: 'rubis_pro_monthly',
|
|
organizationId: org.id,
|
|
status: 'active',
|
|
})
|
|
)
|
|
await org.refresh()
|
|
assert.equal(org.plan, 'pro')
|
|
assert.equal(org.subscriptionStatus, 'active')
|
|
})
|
|
|
|
test('fallback : lookup via stripeCustomerId si metadata absente', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
org.stripeCustomerId = 'cus_lookup_fallback'
|
|
await org.save()
|
|
|
|
await handleSubscriptionUpdate(
|
|
fakeSubscription({
|
|
customerId: 'cus_lookup_fallback',
|
|
lookupKey: 'rubis_pro_monthly',
|
|
status: 'active',
|
|
// pas de metadata.organization_id
|
|
})
|
|
)
|
|
await org.refresh()
|
|
assert.equal(org.plan, 'pro')
|
|
})
|
|
|
|
test('aucun org trouvé → no-op silencieux', async ({ assert }) => {
|
|
await assert.doesNotReject(() =>
|
|
handleSubscriptionUpdate(
|
|
fakeSubscription({
|
|
customerId: 'cus_doesnt_exist_anywhere',
|
|
lookupKey: 'rubis_pro_monthly',
|
|
})
|
|
)
|
|
)
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — handleSubscriptionDeleted', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('Pro avec sub → org passe en free + clear stripe fields', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
org.plan = 'pro'
|
|
org.subscriptionStatus = 'active'
|
|
org.billingCycle = 'monthly'
|
|
org.stripeCustomerId = 'cus_to_delete'
|
|
org.stripeSubscriptionId = 'sub_to_delete'
|
|
org.currentPeriodEnd = DateTime.utc().plus({ days: 10 })
|
|
org.cancelAtPeriodEnd = true
|
|
await org.save()
|
|
|
|
await handleSubscriptionDeleted(
|
|
fakeSubscription({ customerId: 'cus_to_delete', id: 'sub_to_delete' })
|
|
)
|
|
await org.refresh()
|
|
|
|
assert.equal(org.plan, 'free')
|
|
assert.equal(org.subscriptionStatus, 'canceled')
|
|
assert.isNull(org.stripeSubscriptionId)
|
|
assert.isNull(org.billingCycle)
|
|
assert.isNull(org.currentPeriodEnd)
|
|
assert.isFalse(org.cancelAtPeriodEnd)
|
|
})
|
|
|
|
test('trial_ends_at conservé après cancellation (garde-fou anti-relance trial)', async ({
|
|
assert,
|
|
}) => {
|
|
const { org } = await createTestUser()
|
|
const oldTrialEnd = DateTime.utc().minus({ days: 30 })
|
|
org.plan = 'pro'
|
|
org.stripeCustomerId = 'cus_keep_trial'
|
|
org.trialEndsAt = oldTrialEnd
|
|
await org.save()
|
|
|
|
await handleSubscriptionDeleted(
|
|
fakeSubscription({ customerId: 'cus_keep_trial' })
|
|
)
|
|
await org.refresh()
|
|
assert.isNotNull(org.trialEndsAt)
|
|
assert.equal(org.trialEndsAt?.toUnixInteger(), oldTrialEnd.toUnixInteger())
|
|
})
|
|
|
|
test('customer inconnu → no-op', async ({ assert }) => {
|
|
await assert.doesNotReject(() =>
|
|
handleSubscriptionDeleted(fakeSubscription({ customerId: 'cus_ghost' }))
|
|
)
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — handlePaymentFailed', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('invoice.payment_failed → org marquée past_due', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
org.plan = 'pro'
|
|
org.subscriptionStatus = 'active'
|
|
org.stripeCustomerId = 'cus_payment_failed'
|
|
await org.save()
|
|
|
|
await handlePaymentFailed(fakeInvoice({ customerId: 'cus_payment_failed' }))
|
|
await org.refresh()
|
|
assert.equal(org.subscriptionStatus, 'past_due')
|
|
// Plan reste pro pendant le grace period Stripe (smart retries).
|
|
assert.equal(org.plan, 'pro')
|
|
})
|
|
|
|
test('customer null → no-op (jamais d\'invoice détachée)', async ({ assert }) => {
|
|
await assert.doesNotReject(() => handlePaymentFailed(fakeInvoice({ customerId: null })))
|
|
})
|
|
|
|
test('customer inconnu → no-op', async ({ assert }) => {
|
|
await assert.doesNotReject(() =>
|
|
handlePaymentFailed(fakeInvoice({ customerId: 'cus_ghost' }))
|
|
)
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — handleTrialWillEnd (enqueue recap)', (group) => {
|
|
let enqueueCalls: Array<{ orgId: string; subscriptionId: string }> = []
|
|
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
group.each.setup(() => {
|
|
enqueueCalls = []
|
|
__setTrialRecapEnqueueForTests(async (orgId, subscriptionId) => {
|
|
enqueueCalls.push({ orgId, subscriptionId })
|
|
})
|
|
return () => {
|
|
// Reset entre les tests pour ne pas leak entre suites.
|
|
__setTrialRecapEnqueueForTests(async () => {})
|
|
}
|
|
})
|
|
|
|
test('enqueue recap pour l\'org matchée via customerId', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
org.stripeCustomerId = 'cus_trial_will_end'
|
|
await org.save()
|
|
|
|
await handleTrialWillEnd(
|
|
fakeSubscription({
|
|
id: 'sub_trial_99',
|
|
customerId: 'cus_trial_will_end',
|
|
status: 'trialing',
|
|
})
|
|
)
|
|
assert.lengthOf(enqueueCalls, 1)
|
|
assert.equal(enqueueCalls[0]?.orgId, org.id)
|
|
assert.equal(enqueueCalls[0]?.subscriptionId, 'sub_trial_99')
|
|
})
|
|
|
|
test('customer inconnu → pas d\'enqueue', async ({ assert }) => {
|
|
await handleTrialWillEnd(
|
|
fakeSubscription({ customerId: 'cus_ghost', status: 'trialing' })
|
|
)
|
|
assert.lengthOf(enqueueCalls, 0)
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — handleCheckoutCompleted (avec Stripe mock)', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
group.each.teardown(() => uninstallStripeMock())
|
|
|
|
test('retrieve subscription Stripe puis apply → org Pro trialing', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
|
|
|
|
installStripeMock({
|
|
subscriptions: {
|
|
retrieve: (async () =>
|
|
fakeSubscription({
|
|
id: 'sub_completed',
|
|
status: 'trialing',
|
|
lookupKey: 'rubis_pro_monthly',
|
|
trialEnd: trialEndEpoch,
|
|
organizationId: org.id,
|
|
})) as unknown as import('stripe').default['subscriptions']['retrieve'],
|
|
},
|
|
})
|
|
|
|
await handleCheckoutCompleted(
|
|
fakeCheckoutSession({
|
|
id: 'cs_done',
|
|
organizationId: org.id,
|
|
subscriptionId: 'sub_completed',
|
|
})
|
|
)
|
|
await org.refresh()
|
|
assert.equal(org.plan, 'pro')
|
|
assert.equal(org.subscriptionStatus, 'trialing')
|
|
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
|
|
})
|
|
|
|
test('session sans subscription → no-op', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
await handleCheckoutCompleted(
|
|
fakeCheckoutSession({ organizationId: org.id, subscriptionId: null })
|
|
)
|
|
await org.refresh()
|
|
assert.equal(org.plan, 'free') // pas touché
|
|
})
|
|
|
|
test('session sans organization_id metadata → no-op', async ({ assert }) => {
|
|
await handleCheckoutCompleted(
|
|
fakeCheckoutSession({
|
|
organizationId: null,
|
|
subscriptionId: 'sub_should_be_ignored',
|
|
})
|
|
)
|
|
// Pas d'assertion DB nécessaire — le no-op réussit silencieusement.
|
|
assert.isTrue(true)
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — createTrialCheckoutSession (idempotence)', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
group.each.teardown(() => uninstallStripeMock())
|
|
|
|
test('throw TrialAlreadyConsumedError si trialEndsAt déjà posé', async ({ assert }) => {
|
|
const { org } = await createTestUser()
|
|
org.trialEndsAt = DateTime.utc().minus({ days: 30 })
|
|
org.stripeCustomerId = 'cus_test_idem'
|
|
await org.save()
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
createTrialCheckoutSession({
|
|
org,
|
|
customerId: 'cus_test_idem',
|
|
plan: 'pro',
|
|
cycle: 'monthly',
|
|
}),
|
|
TrialAlreadyConsumedError
|
|
)
|
|
})
|
|
|
|
test('throw TrialAlreadyConsumedError si stripeSubscriptionId déjà posé', async ({
|
|
assert,
|
|
}) => {
|
|
const { org } = await createTestUser()
|
|
org.stripeSubscriptionId = 'sub_existing'
|
|
org.stripeCustomerId = 'cus_test_idem2'
|
|
await org.save()
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
createTrialCheckoutSession({
|
|
org,
|
|
customerId: 'cus_test_idem2',
|
|
plan: 'pro',
|
|
cycle: 'monthly',
|
|
}),
|
|
TrialAlreadyConsumedError
|
|
)
|
|
})
|
|
|
|
test('happy path : crée la session Checkout avec trial_period_days=14', async ({
|
|
assert,
|
|
}) => {
|
|
const { org } = await createTestUser()
|
|
org.stripeCustomerId = 'cus_happy'
|
|
await org.save()
|
|
|
|
const sessionCalls: Array<Record<string, unknown>> = []
|
|
installStripeMock({
|
|
prices: {
|
|
list: (async () => ({
|
|
data: [{ id: 'price_pro_monthly_test' }],
|
|
})) as unknown as import('stripe').default['prices']['list'],
|
|
},
|
|
checkout: {
|
|
sessions: {
|
|
create: (async (params: Record<string, unknown>) => {
|
|
sessionCalls.push(params)
|
|
return { id: 'cs_new', url: 'https://checkout.stripe.test/cs_new' }
|
|
}) as unknown as import('stripe').default['checkout']['sessions']['create'],
|
|
},
|
|
},
|
|
})
|
|
|
|
const result = await createTrialCheckoutSession({
|
|
org,
|
|
customerId: 'cus_happy',
|
|
plan: 'pro',
|
|
cycle: 'monthly',
|
|
})
|
|
|
|
assert.equal(result.url, 'https://checkout.stripe.test/cs_new')
|
|
assert.lengthOf(sessionCalls, 1)
|
|
const params = sessionCalls[0] as {
|
|
subscription_data?: { trial_period_days?: number; metadata?: Record<string, string> }
|
|
metadata?: Record<string, string>
|
|
}
|
|
assert.equal(params.subscription_data?.trial_period_days, 14)
|
|
assert.equal(params.subscription_data?.metadata?.['organization_id'], org.id)
|
|
assert.equal(params.metadata?.['is_trial'], 'true')
|
|
})
|
|
})
|
|
|
|
test.group('stripe_billing — dispatchWebhookEvent (router)', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('event inconnu → no-op silencieux (pas de throw)', async ({ assert }) => {
|
|
const evt = {
|
|
id: 'evt_unknown',
|
|
type: 'customer.tax_id.created',
|
|
data: { object: {} },
|
|
} as unknown as import('stripe').default.Event
|
|
await assert.doesNotReject(() => dispatchWebhookEvent(evt))
|
|
})
|
|
|
|
test('customer.subscription.deleted route bien vers handleSubscriptionDeleted', async ({
|
|
assert,
|
|
}) => {
|
|
const { org } = await createTestUser()
|
|
org.plan = 'pro'
|
|
org.stripeCustomerId = 'cus_routed'
|
|
org.stripeSubscriptionId = 'sub_routed'
|
|
await org.save()
|
|
|
|
const evt = {
|
|
id: 'evt_deleted',
|
|
type: 'customer.subscription.deleted',
|
|
data: {
|
|
object: fakeSubscription({ customerId: 'cus_routed', id: 'sub_routed' }),
|
|
},
|
|
} as unknown as import('stripe').default.Event
|
|
|
|
await dispatchWebhookEvent(evt)
|
|
await org.refresh()
|
|
assert.equal(org.plan, 'free')
|
|
assert.equal(org.subscriptionStatus, 'canceled')
|
|
})
|
|
})
|