test(billing): tests E2E HTTP du tunnel essai 14 j + playbook Stripe test mode
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
Ajoute 16 tests E2E qui hit les vraies routes `/api/v1/billing/*` à
travers le middleware auth, les validators et la persistance DB.
Complémentaire des 60 tests unitaires sur les services.
Suites couvertes :
- POST /start-trial : 200 happy path, customer Stripe réutilisé,
409 trial déjà consommé (2 garde-fous), 401 sans Bearer
- GET /subscription : expose inTrial + trialEndsAt, garde-fou
trial_ends_at passé
- POST /webhook : checkout.completed, subscription.updated trialing→active,
trial_will_end → enqueue recap (avec spy), payment_failed → past_due,
subscription.deleted → free + trial_ends_at conservé
- Idempotence : 2× le même event = même état final
- Event type inconnu → 200 silencieux (pas de DB write)
- 400 si stripe-signature absent / signature invalide
Helpers de test :
- installFullStripeMock(opts) → mock complet : customers, prices,
checkout, billingPortal, subscriptions, webhooks. Avec
passThroughWebhook qui bypass la vérif signature pour tester
le routing applicatif sans signer manuellement chaque payload.
env.test : STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET dummy +
WEB_URL/LANDING_URL.
Documentation : docs/tech/stripe-trial-e2e-playbook.md — playbook
manuel pour valider en mode Stripe test (5 scénarios : happy path
3DS, carte refusée au prélèvement, annulation Customer Portal,
re-trial bloqué, fallback Free). Utilise Stripe Test Clocks pour
fast-forward sans attendre 14 jours réels.
Total après ce commit : 76 tests sur la chaîne billing (60 unit + 16 E2E).
Les cas Stripe-side (3DS UI réel, prélèvement effectif J+14) restent
à valider manuellement via le playbook avant le go-live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b0e6f83655
commit
094c26059f
@ -10,3 +10,12 @@ OCR_PROVIDER=mock
|
||||
# Utilise la même DB que dev avec global transactions par test (rollback).
|
||||
# Si tu veux une DB séparée : crée `rubis_test` dans Postgres et override
|
||||
# PG_DB_NAME=rubis_test ici.
|
||||
|
||||
# Stripe — clés factices. Le SDK est mocké au niveau singleton via
|
||||
# __setStripeForTests() (cf. tests/helpers/stripe_mock.ts), donc ces
|
||||
# valeurs ne touchent jamais Stripe. STRIPE_WEBHOOK_SECRET sert aux
|
||||
# tests E2E qui signent eux-mêmes les payloads via crypto HMAC.
|
||||
STRIPE_SECRET_KEY=sk_test_dummy_for_unit_tests
|
||||
STRIPE_WEBHOOK_SECRET=whsec_test_dummy_for_unit_tests
|
||||
WEB_URL=http://localhost:5173
|
||||
LANDING_URL=http://localhost:5174
|
||||
|
||||
517
apps/api/tests/functional/billing_trial.spec.ts
Normal file
517
apps/api/tests/functional/billing_trial.spec.ts
Normal file
@ -0,0 +1,517 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
import Organization from '#models/organization'
|
||||
import {
|
||||
__setStripeForTests,
|
||||
} from '#services/stripe'
|
||||
import { __setTrialRecapEnqueueForTests } from '#services/stripe_billing'
|
||||
|
||||
import { createTestUser } from '../helpers/auth.js'
|
||||
import { body, type ApiOk } from '../helpers/response.js'
|
||||
import { fakeSubscription } from '../helpers/stripe_mock.js'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
/**
|
||||
* Tests E2E HTTP du tunnel billing trial 14 j.
|
||||
*
|
||||
* Couverture :
|
||||
* - POST /api/v1/billing/start-trial → 200 + URL Stripe (mocked)
|
||||
* - POST /api/v1/billing/start-trial → 409 si trial déjà consommé
|
||||
* - GET /api/v1/billing/subscription → reflète inTrial / trialEndsAt
|
||||
* - POST /api/v1/billing/webhook → checkout.completed → org devient
|
||||
* trialing avec trial_ends_at posé
|
||||
* - POST /api/v1/billing/webhook → customer.subscription.updated
|
||||
* (trialing → active) → org passe en active
|
||||
* - POST /api/v1/billing/webhook → trial_will_end → recap enqueué
|
||||
* - POST /api/v1/billing/webhook → invoice.payment_failed → past_due
|
||||
* - POST /api/v1/billing/webhook → subscription.deleted → free
|
||||
* - Idempotence : 2× le même event = même état final
|
||||
*
|
||||
* Stratégie de mocking : le SDK Stripe est injecté via __setStripeForTests,
|
||||
* y compris `webhooks.constructEvent` qui retourne le payload parsé
|
||||
* directement (on bypass la vérif signature — bien testée par Stripe
|
||||
* eux-mêmes, on teste ici le routing applicatif).
|
||||
*
|
||||
* Les jobs BullMQ sont stubés via __setTrialRecapEnqueueForTests pour
|
||||
* que les tests passent sans Redis.
|
||||
*/
|
||||
|
||||
const WEBHOOK_PATH = '/api/v1/billing/webhook'
|
||||
const TRIAL_PATH = '/api/v1/billing/start-trial'
|
||||
const SUB_PATH = '/api/v1/billing/subscription'
|
||||
|
||||
/**
|
||||
* Mock minimal du SDK Stripe pour les routes billing. On override toutes
|
||||
* les méthodes appelées par les handlers du chantier trial.
|
||||
*/
|
||||
type StripeMockOpts = {
|
||||
/** Stripe.subscriptions.retrieve(id) → renvoyer ce subscription. */
|
||||
subscriptionFixture?: Stripe.Subscription
|
||||
/** Forcer customers.create à renvoyer cet id (default: cus_test_<random>). */
|
||||
customerId?: string
|
||||
/** URL retournée par checkout.sessions.create. */
|
||||
checkoutUrl?: string
|
||||
/**
|
||||
* Le handler webhook appelle stripe.webhooks.constructEvent(raw, sig, secret).
|
||||
* On retourne directement event en bypassant la vérif signature pour
|
||||
* concentrer les tests sur le routing applicatif.
|
||||
*/
|
||||
passThroughWebhook?: boolean
|
||||
}
|
||||
|
||||
function installFullStripeMock(opts: StripeMockOpts = {}) {
|
||||
const customerId = opts.customerId ?? `cus_${Math.random().toString(36).slice(2, 10)}`
|
||||
const checkoutUrl = opts.checkoutUrl ?? 'https://checkout.stripe.test/cs_e2e'
|
||||
|
||||
const mock = {
|
||||
customers: {
|
||||
create: async (params: Record<string, unknown>) => ({
|
||||
id: customerId,
|
||||
email: params.email,
|
||||
metadata: params.metadata,
|
||||
}),
|
||||
},
|
||||
prices: {
|
||||
list: async () => ({
|
||||
data: [{ id: 'price_pro_monthly_test' } as unknown as Stripe.Price],
|
||||
}),
|
||||
},
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: async () => ({ id: 'cs_test', url: checkoutUrl }),
|
||||
},
|
||||
},
|
||||
billingPortal: {
|
||||
sessions: {
|
||||
create: async () => ({ url: 'https://billing.stripe.test/portal' }),
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
retrieve: async () => opts.subscriptionFixture ?? fakeSubscription({}),
|
||||
},
|
||||
webhooks: {
|
||||
constructEvent: opts.passThroughWebhook
|
||||
? (raw: string | Buffer) => JSON.parse(raw.toString())
|
||||
: () => {
|
||||
throw new Error('webhooks.constructEvent: signature check bypassed in test')
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe
|
||||
|
||||
__setStripeForTests(mock)
|
||||
return { customerId, checkoutUrl }
|
||||
}
|
||||
|
||||
function uninstallStripeMock() {
|
||||
__setStripeForTests(null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2E : POST /billing/start-trial
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.group('Billing E2E — POST /start-trial', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
group.each.teardown(() => uninstallStripeMock())
|
||||
|
||||
test('200 → renvoie l\'URL Stripe Checkout (et persist Stripe customerId)', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { bearer, org } = await createTestUser()
|
||||
const { customerId, checkoutUrl } = installFullStripeMock()
|
||||
|
||||
const response = await client.post(TRIAL_PATH).headers(bearer).json({})
|
||||
|
||||
response.assertStatus(200)
|
||||
const payload = body<ApiOk<{ url: string }>>(response)
|
||||
assert.equal(payload.data.url, checkoutUrl)
|
||||
|
||||
// Le customer Stripe a été persisté côté org.
|
||||
await org.refresh()
|
||||
assert.equal(org.stripeCustomerId, customerId)
|
||||
})
|
||||
|
||||
test('200 → réutilise le customer existant (idempotence ensureStripeCustomer)', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { bearer, org } = await createTestUser()
|
||||
org.stripeCustomerId = 'cus_already_there'
|
||||
await org.save()
|
||||
|
||||
let customersCreateCalls = 0
|
||||
const mock = {
|
||||
customers: {
|
||||
create: async () => {
|
||||
customersCreateCalls += 1
|
||||
return { id: 'cus_should_not_be_called' }
|
||||
},
|
||||
},
|
||||
prices: { list: async () => ({ data: [{ id: 'price_test' }] }) },
|
||||
checkout: {
|
||||
sessions: { create: async () => ({ id: 'cs_x', url: 'https://test/cs' }) },
|
||||
},
|
||||
} as unknown as Stripe
|
||||
__setStripeForTests(mock)
|
||||
|
||||
const response = await client.post(TRIAL_PATH).headers(bearer).json({})
|
||||
response.assertStatus(200)
|
||||
|
||||
await org.refresh()
|
||||
assert.equal(org.stripeCustomerId, 'cus_already_there')
|
||||
assert.equal(customersCreateCalls, 0, 'customers.create ne doit pas être appelé')
|
||||
})
|
||||
|
||||
test('409 → trial déjà consommé (trialEndsAt déjà posé)', async ({ client }) => {
|
||||
const { bearer, org } = await createTestUser()
|
||||
org.trialEndsAt = DateTime.utc().minus({ days: 5 })
|
||||
org.stripeCustomerId = 'cus_consumed'
|
||||
await org.save()
|
||||
installFullStripeMock({ customerId: 'cus_consumed' })
|
||||
|
||||
const response = await client.post(TRIAL_PATH).headers(bearer).json({})
|
||||
response.assertStatus(409)
|
||||
})
|
||||
|
||||
test('409 → trial déjà consommé (stripeSubscriptionId déjà posé)', async ({ client }) => {
|
||||
const { bearer, org } = await createTestUser()
|
||||
org.stripeSubscriptionId = 'sub_existing'
|
||||
org.stripeCustomerId = 'cus_existing'
|
||||
await org.save()
|
||||
installFullStripeMock({ customerId: 'cus_existing' })
|
||||
|
||||
const response = await client.post(TRIAL_PATH).headers(bearer).json({})
|
||||
response.assertStatus(409)
|
||||
})
|
||||
|
||||
test('401 → sans Bearer token', async ({ client }) => {
|
||||
installFullStripeMock()
|
||||
const response = await client.post(TRIAL_PATH).json({})
|
||||
response.assertStatus(401)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2E : GET /billing/subscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.group('Billing E2E — GET /subscription (trial state)', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('inTrial=true quand status=trialing + trial_ends_at futur', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { bearer, org } = await createTestUser()
|
||||
org.subscriptionStatus = 'trialing'
|
||||
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
|
||||
await org.save()
|
||||
|
||||
const response = await client.get(SUB_PATH).headers(bearer)
|
||||
response.assertStatus(200)
|
||||
const state = body<ApiOk<{ inTrial: boolean; trialEndsAt: string | null }>>(response)
|
||||
assert.isTrue(state.data.inTrial)
|
||||
assert.isNotNull(state.data.trialEndsAt)
|
||||
})
|
||||
|
||||
test('inTrial=false quand trial_ends_at passé (garde-fou)', async ({ client, assert }) => {
|
||||
const { bearer, org } = await createTestUser()
|
||||
org.subscriptionStatus = 'trialing'
|
||||
org.trialEndsAt = DateTime.utc().minus({ minutes: 1 })
|
||||
await org.save()
|
||||
|
||||
const response = await client.get(SUB_PATH).headers(bearer)
|
||||
response.assertStatus(200)
|
||||
const state = body<ApiOk<{ inTrial: boolean }>>(response)
|
||||
assert.isFalse(state.data.inTrial)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2E : POST /billing/webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.group('Billing E2E — POST /webhook (dispatcher applicatif)', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
group.each.teardown(() => {
|
||||
uninstallStripeMock()
|
||||
__setTrialRecapEnqueueForTests(async () => {})
|
||||
})
|
||||
|
||||
test('checkout.session.completed → org en trialing + trial_ends_at', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
|
||||
|
||||
installFullStripeMock({
|
||||
passThroughWebhook: true,
|
||||
subscriptionFixture: fakeSubscription({
|
||||
id: 'sub_e2e_checkout',
|
||||
customerId: 'cus_e2e_checkout',
|
||||
status: 'trialing',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
trialEnd: trialEndEpoch,
|
||||
organizationId: org.id,
|
||||
}),
|
||||
})
|
||||
|
||||
const event = {
|
||||
id: 'evt_checkout_1',
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'cs_e2e',
|
||||
subscription: 'sub_e2e_checkout',
|
||||
metadata: { organization_id: org.id },
|
||||
},
|
||||
},
|
||||
}
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
|
||||
response.assertStatus(200)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'pro')
|
||||
assert.equal(org.subscriptionStatus, 'trialing')
|
||||
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
|
||||
assert.equal(org.stripeSubscriptionId, 'sub_e2e_checkout')
|
||||
})
|
||||
|
||||
test('customer.subscription.updated (trial → active) → org devient active', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
org.plan = 'pro'
|
||||
org.subscriptionStatus = 'trialing'
|
||||
org.stripeCustomerId = 'cus_e2e_upd'
|
||||
org.stripeSubscriptionId = 'sub_e2e_upd'
|
||||
org.trialEndsAt = DateTime.utc().minus({ hours: 1 })
|
||||
await org.save()
|
||||
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
|
||||
const event = {
|
||||
id: 'evt_upd_1',
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: fakeSubscription({
|
||||
id: 'sub_e2e_upd',
|
||||
customerId: 'cus_e2e_upd',
|
||||
status: 'active',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
organizationId: org.id,
|
||||
}),
|
||||
},
|
||||
}
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
response.assertStatus(200)
|
||||
|
||||
await org.refresh()
|
||||
assert.equal(org.subscriptionStatus, 'active')
|
||||
})
|
||||
|
||||
test('customer.subscription.trial_will_end → enqueue recap pour cette org', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const enqueueCalls: Array<{ orgId: string; subId: string }> = []
|
||||
__setTrialRecapEnqueueForTests(async (orgId, subId) => {
|
||||
enqueueCalls.push({ orgId, subId })
|
||||
})
|
||||
|
||||
const { org } = await createTestUser()
|
||||
org.stripeCustomerId = 'cus_e2e_will_end'
|
||||
await org.save()
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
|
||||
const event = {
|
||||
id: 'evt_will_end_1',
|
||||
type: 'customer.subscription.trial_will_end',
|
||||
data: {
|
||||
object: fakeSubscription({
|
||||
id: 'sub_e2e_will_end',
|
||||
customerId: 'cus_e2e_will_end',
|
||||
status: 'trialing',
|
||||
}),
|
||||
},
|
||||
}
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
response.assertStatus(200)
|
||||
assert.lengthOf(enqueueCalls, 1)
|
||||
assert.equal(enqueueCalls[0]?.orgId, org.id)
|
||||
assert.equal(enqueueCalls[0]?.subId, 'sub_e2e_will_end')
|
||||
})
|
||||
|
||||
test('invoice.payment_failed → org passe en past_due', async ({ client, assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.plan = 'pro'
|
||||
org.subscriptionStatus = 'active'
|
||||
org.stripeCustomerId = 'cus_e2e_failed'
|
||||
await org.save()
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
|
||||
const event = {
|
||||
id: 'evt_failed_1',
|
||||
type: 'invoice.payment_failed',
|
||||
data: { object: { id: 'in_1', customer: 'cus_e2e_failed', status: 'open' } },
|
||||
}
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
response.assertStatus(200)
|
||||
|
||||
await org.refresh()
|
||||
assert.equal(org.subscriptionStatus, 'past_due')
|
||||
assert.equal(org.plan, 'pro', 'le plan reste pro pendant les Stripe smart retries')
|
||||
})
|
||||
|
||||
test('customer.subscription.deleted → bascule free + clear stripe', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
org.plan = 'pro'
|
||||
org.subscriptionStatus = 'active'
|
||||
org.stripeCustomerId = 'cus_e2e_del'
|
||||
org.stripeSubscriptionId = 'sub_e2e_del'
|
||||
org.billingCycle = 'monthly'
|
||||
org.trialEndsAt = DateTime.utc().minus({ days: 30 })
|
||||
await org.save()
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
|
||||
const event = {
|
||||
id: 'evt_del_1',
|
||||
type: 'customer.subscription.deleted',
|
||||
data: {
|
||||
object: fakeSubscription({
|
||||
id: 'sub_e2e_del',
|
||||
customerId: 'cus_e2e_del',
|
||||
}),
|
||||
},
|
||||
}
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
response.assertStatus(200)
|
||||
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'free')
|
||||
assert.equal(org.subscriptionStatus, 'canceled')
|
||||
assert.isNull(org.stripeSubscriptionId)
|
||||
assert.isNotNull(org.trialEndsAt, 'trial_ends_at conservé pour l\'historique')
|
||||
})
|
||||
|
||||
test('idempotence : 2× le même event = même état final', async ({ client, assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.stripeCustomerId = 'cus_e2e_idem'
|
||||
org.subscriptionStatus = 'trialing'
|
||||
org.trialEndsAt = DateTime.utc().plus({ days: 5 })
|
||||
await org.save()
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
|
||||
const event = {
|
||||
id: 'evt_idem_1',
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: fakeSubscription({
|
||||
id: 'sub_idem',
|
||||
customerId: 'cus_e2e_idem',
|
||||
status: 'active',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
organizationId: org.id,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const r1 = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
r1.assertStatus(200)
|
||||
await org.refresh()
|
||||
const snapshot1 = {
|
||||
plan: org.plan,
|
||||
status: org.subscriptionStatus,
|
||||
subId: org.stripeSubscriptionId,
|
||||
}
|
||||
|
||||
const r2 = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
r2.assertStatus(200)
|
||||
await org.refresh()
|
||||
const snapshot2 = {
|
||||
plan: org.plan,
|
||||
status: org.subscriptionStatus,
|
||||
subId: org.stripeSubscriptionId,
|
||||
}
|
||||
|
||||
assert.deepEqual(snapshot1, snapshot2)
|
||||
})
|
||||
|
||||
test('event type inconnu → 200 silencieux (pas de throw, pas de DB write)', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
await org.refresh()
|
||||
const beforePlan = org.plan
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
|
||||
const event = {
|
||||
id: 'evt_unknown',
|
||||
type: 'customer.tax_id.created',
|
||||
data: { object: {} },
|
||||
}
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'dummy')
|
||||
.json(event)
|
||||
response.assertStatus(200)
|
||||
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, beforePlan, 'org pas modifiée par event inconnu')
|
||||
})
|
||||
|
||||
test('400 si stripe-signature header absent', async ({ client }) => {
|
||||
installFullStripeMock({ passThroughWebhook: true })
|
||||
const response = await client.post(WEBHOOK_PATH).json({ type: 'whatever' })
|
||||
response.assertStatus(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2E : signature validation (bypass désactivé)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.group('Billing E2E — POST /webhook (signature)', (group) => {
|
||||
group.each.teardown(() => uninstallStripeMock())
|
||||
|
||||
test('400 si signature invalide (constructEvent throw)', async ({ client }) => {
|
||||
// passThroughWebhook=false → notre constructEvent mocké throw, simulant
|
||||
// ce que Stripe ferait sur une signature corrompue.
|
||||
installFullStripeMock({ passThroughWebhook: false })
|
||||
const response = await client
|
||||
.post(WEBHOOK_PATH)
|
||||
.header('stripe-signature', 'bad_signature_payload')
|
||||
.json({ type: 'whatever' })
|
||||
response.assertStatus(400)
|
||||
})
|
||||
})
|
||||
247
docs/tech/stripe-trial-e2e-playbook.md
Normal file
247
docs/tech/stripe-trial-e2e-playbook.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Playbook E2E manuel — Essai 14 j Pro avec CB (Stripe test mode)
|
||||
|
||||
> Version : 0.1 · Dernière maj : 2026-05-18
|
||||
> Référence : `docs/tech/stripe-trial-with-card.md`
|
||||
|
||||
Validation manuelle bout en bout du tunnel essai 14 j avant le go-live. À exécuter UNE FOIS avant le premier déploiement prod du chantier billing, puis à re-jouer après chaque modif des handlers webhook.
|
||||
|
||||
**Pré-requis non triviaux à connaître** :
|
||||
|
||||
- Compte Stripe en mode **test** (Dashboard → "Viewing test data" en haut à droite — toggle bien sur "TEST").
|
||||
- **Stripe CLI** installé : `brew install stripe/stripe-cli/stripe` (sinon `stripe.com/docs/stripe-cli`).
|
||||
- Redis local (BullMQ) : `docker compose -f docker-compose.dev.yml up redis -d` depuis la racine du repo.
|
||||
- Postgres local + base `rubis` migrée : `pnpm --filter @rubis/api migration:run`.
|
||||
|
||||
Les tests **automatisés** couvrent déjà tous les cas applicatifs (cf. `apps/api/tests/unit/stripe_billing.spec.ts` + `apps/api/tests/functional/billing_trial.spec.ts`). Ce playbook ajoute la validation des cas que seul Stripe peut produire : **3DS challenge réel**, **prélèvement effectif à J+14**, **passage `trialing → active` natif**.
|
||||
|
||||
---
|
||||
|
||||
## Setup ponctuel (une seule fois)
|
||||
|
||||
### 1. Récupérer la clé webhook test
|
||||
|
||||
```bash
|
||||
stripe login # ouvre browser, lie ton compte Stripe au CLI
|
||||
stripe listen --forward-to http://localhost:3333/api/v1/billing/webhook
|
||||
```
|
||||
|
||||
Le CLI affiche un `whsec_xxxx` qu'il faut copier dans ton `.env` local :
|
||||
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_test_... # depuis Dashboard → Developers → API keys
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxx # depuis l'output stripe listen
|
||||
WEB_URL=http://localhost:5173
|
||||
LANDING_URL=http://localhost:5174
|
||||
```
|
||||
|
||||
**Laisse `stripe listen` tourner** dans un terminal séparé pendant tout le test — c'est lui qui forward les webhooks Stripe → ta machine.
|
||||
|
||||
### 2. Vérifier les Prices Stripe test
|
||||
|
||||
```bash
|
||||
pnpm --filter @rubis/api exec node ace stripe:setup
|
||||
```
|
||||
|
||||
Crée les 4 Prices avec les lookup keys `rubis_pro_monthly`, `rubis_pro_yearly`, `rubis_business_monthly`, `rubis_business_yearly`. Vérifie dans Dashboard → Products que les 4 sont actifs.
|
||||
|
||||
### 3. Démarrer l'app
|
||||
|
||||
3 terminaux :
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Stripe webhook forwarder (laisser tourner)
|
||||
stripe listen --forward-to http://localhost:3333/api/v1/billing/webhook
|
||||
|
||||
# Terminal 2 — API + SPA + landing
|
||||
pnpm dev
|
||||
|
||||
# Terminal 3 — interactions avec Stripe (test clock, cards)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scénario 1 — Happy path : essai → prélèvement → actif
|
||||
|
||||
**Objectif** : valider qu'un signup avec carte 3DS passe en trial, puis bascule automatiquement en `active` à l'expiration.
|
||||
|
||||
### Étapes
|
||||
|
||||
1. **Signup** sur http://localhost:5173/signup avec un email frais (`alice+test@rubis.test`).
|
||||
2. **Onboarding billing** — naviguer manuellement vers http://localhost:5173/onboarding/billing (l'écran n'est pas encore forcé dans le flow).
|
||||
3. Cliquer **"Démarrer mon essai 14 jours"**.
|
||||
4. Stripe Checkout s'ouvre. Saisir la carte 3DS test :
|
||||
- Numéro : `4000 0027 6000 3184`
|
||||
- Date : n'importe quelle date future
|
||||
- CVC : `123`
|
||||
- Code postal : `75001`
|
||||
5. **3DS challenge** apparaît (Stripe simule la modale auth banque). Cliquer **"Complete authentication"** dans la modale.
|
||||
6. Stripe redirige vers `http://localhost:5173/onboarding/compte?trial=started&session_id=cs_xxx`.
|
||||
|
||||
### Vérifications immédiates
|
||||
|
||||
- [ ] **DB org** : `SELECT plan, subscription_status, trial_ends_at FROM organizations WHERE id = '<org_id>';`
|
||||
- `plan = 'pro'`
|
||||
- `subscription_status = 'trialing'`
|
||||
- `trial_ends_at` ≈ now + 14 jours
|
||||
- [ ] **App SPA** : la bannière "Essai Pro · 14 jours restants" s'affiche en haut de `/factures`.
|
||||
- [ ] **Stripe Dashboard → Customers** : le customer est créé avec metadata `organization_id`.
|
||||
- [ ] **Stripe Dashboard → Subscriptions** : la subscription est en `trialing` jusqu'au `trial_end`.
|
||||
|
||||
### Fast-forward avec Stripe Test Clock
|
||||
|
||||
Pour valider la suite (J+11 recap + J+14 prélèvement) sans attendre 14 jours réels :
|
||||
|
||||
```bash
|
||||
# 1. Récupère le customer_id et le subscription_id côté Dashboard
|
||||
CUSTOMER_ID="cus_xxx"
|
||||
SUBSCRIPTION_ID="sub_xxx"
|
||||
|
||||
# 2. Crée un test clock à T0 (now)
|
||||
stripe test_helpers test_clocks create \
|
||||
--frozen-time "$(date +%s)" \
|
||||
--name "rubis-trial-e2e"
|
||||
|
||||
# Copie le CLOCK_ID retourné. Puis attache le customer au test clock :
|
||||
stripe customers update "$CUSTOMER_ID" \
|
||||
--test-clock "$CLOCK_ID"
|
||||
```
|
||||
|
||||
⚠️ **Important** : il faut **recommencer le signup** depuis zéro avec le customer attaché au test clock (Stripe interdit d'attacher un clock à un customer existant). Solution : créer le customer **AVANT** le signup avec le test clock attaché, puis hack temporairement `ensureStripeCustomer` pour le réutiliser, ou utiliser un endpoint admin de test (à créer en V2 si on en fait beaucoup).
|
||||
|
||||
Pour ce playbook V1, l'option pragmatique :
|
||||
|
||||
```bash
|
||||
# Advance le clock à J+11 (3 jours avant trial_end)
|
||||
TARGET_J11=$(( $(date +%s) + 11 * 24 * 3600 ))
|
||||
stripe test_helpers test_clocks advance "$CLOCK_ID" --frozen-time "$TARGET_J11"
|
||||
|
||||
# Stripe émet alors customer.subscription.trial_will_end qui arrive sur
|
||||
# notre webhook via stripe listen. Vérifie le terminal 1 :
|
||||
# [200] POST http://localhost:3333/api/v1/billing/webhook [evt_xxx]
|
||||
```
|
||||
|
||||
### Vérifications J+11
|
||||
|
||||
- [ ] **Terminal 1** (`stripe listen`) affiche un POST `customer.subscription.trial_will_end` → 200.
|
||||
- [ ] **DB BullMQ** (Redis) : le job `trial-recap:<sub_id>` apparaît dans la queue. Vérifier via `redis-cli`:
|
||||
```bash
|
||||
redis-cli KEYS 'bull:trial-recap:*'
|
||||
```
|
||||
- [ ] **Mailpit** (http://localhost:8025 si `MAIL_DRIVER=smtp`) : un mail "Plus que 3 jours d'essai" arrive avec :
|
||||
- Sujet : `Plus que 3 jours d'essai · récap avant prélèvement`
|
||||
- Stats : factures importées, relances envoyées, € récupérés, rubis
|
||||
- Bouton "Gérer mon abonnement" → URL Customer Portal Stripe
|
||||
|
||||
### Fast-forward à J+14
|
||||
|
||||
```bash
|
||||
TARGET_J14=$(( $(date +%s) + 14 * 24 * 3600 ))
|
||||
stripe test_helpers test_clocks advance "$CLOCK_ID" --frozen-time "$TARGET_J14"
|
||||
```
|
||||
|
||||
### Vérifications J+14 — happy path
|
||||
|
||||
- [ ] **Terminal 1** : webhook `invoice.created` + `invoice.paid` + `customer.subscription.updated` (status `trialing → active`).
|
||||
- [ ] **DB org** : `subscription_status = 'active'`.
|
||||
- [ ] **App SPA** : la bannière "Essai Pro" disparaît, l'écran `/parametres/abonnement` affiche "Rubis Pro · Mensuel actif".
|
||||
- [ ] **Stripe Dashboard → Payments** : un Payment Intent réussi à 19 €.
|
||||
|
||||
---
|
||||
|
||||
## Scénario 2 — Carte refusée au prélèvement
|
||||
|
||||
**Objectif** : valider qu'une CB qui passe l'auth 3DS mais qui échoue au prélèvement à J+14 fait passer l'org en `past_due`.
|
||||
|
||||
### Étapes
|
||||
|
||||
1. Signup neuf (`bob+failed@rubis.test`).
|
||||
2. **Onboarding billing → Démarrer essai**.
|
||||
3. Stripe Checkout : utiliser la carte **decline-on-charge** :
|
||||
- Numéro : `4000 0000 0000 0341`
|
||||
- (Cette carte accepte le SetupIntent + 3DS mais REFUSE le PaymentIntent au moment du prélèvement.)
|
||||
4. Compléter le 3DS challenge.
|
||||
5. Vérifier que l'org est bien `trialing` (comme scénario 1 jusqu'ici).
|
||||
6. Avancer le test clock à J+14 (voir scénario 1).
|
||||
|
||||
### Vérifications
|
||||
|
||||
- [ ] **Terminal 1** : webhook `invoice.payment_failed` reçu.
|
||||
- [ ] **DB org** : `subscription_status = 'past_due'`, `plan` reste `'pro'` (smart retries Stripe pendant 7 jours).
|
||||
- [ ] **App SPA** : bandeau "Paiement échoué — mettez à jour votre carte" sur `/parametres/abonnement` (à implémenter — actuellement le UI affiche juste le status sans appel à l'action explicite, c'est OK pour V1).
|
||||
- [ ] Si on attend 7 jours supplémentaires (test clock advance), `customer.subscription.deleted` arrive → org bascule en `free` avec `trial_ends_at` conservé en historique.
|
||||
|
||||
---
|
||||
|
||||
## Scénario 3 — Annulation depuis Customer Portal pendant l'essai
|
||||
|
||||
**Objectif** : valider qu'un user qui clique "Annuler mon abonnement" pendant l'essai garde son accès jusqu'à `trial_end` puis bascule en Free.
|
||||
|
||||
### Étapes
|
||||
|
||||
1. Signup + essai démarré comme scénario 1.
|
||||
2. Aller sur `/parametres/abonnement` → cliquer **"Gérer mon abonnement"**.
|
||||
3. Stripe Customer Portal s'ouvre. Cliquer **"Cancel subscription"**.
|
||||
4. Choisir "At end of trial period" (option par défaut pendant un trial).
|
||||
|
||||
### Vérifications
|
||||
|
||||
- [ ] Webhook `customer.subscription.updated` reçu avec `cancel_at_period_end: true`.
|
||||
- [ ] **DB org** : `cancel_at_period_end = true`, `subscription_status` reste `trialing`.
|
||||
- [ ] **App SPA** : `/parametres/abonnement` affiche "Annulé · accès jusqu'au DD/MM" + bouton "Réactiver".
|
||||
- [ ] Advance test clock à J+14 → webhook `customer.subscription.deleted` → DB : `plan='free'`, `subscription_status='canceled'`, `trial_ends_at` toujours posé (historique).
|
||||
|
||||
---
|
||||
|
||||
## Scénario 4 — Re-trial bloqué (idempotence garde-fou)
|
||||
|
||||
**Objectif** : vérifier que l'API renvoie 409 quand un user qui a déjà eu son essai tente d'en démarrer un second.
|
||||
|
||||
### Étapes
|
||||
|
||||
1. Reprendre un compte qui a déjà eu un trial (le user de scénario 1, par exemple).
|
||||
2. Côté frontend, naviguer à nouveau sur `/onboarding/billing` et cliquer "Démarrer mon essai 14 jours".
|
||||
|
||||
### Vérifications
|
||||
|
||||
- [ ] **Network tab** : `POST /api/v1/billing/start-trial` → **409 Conflict** avec `error.code = 'trial_already_consumed'`.
|
||||
- [ ] **SPA** : redirige vers `/parametres/abonnement` avec toast info "Essai déjà utilisé".
|
||||
- [ ] **DB org** : aucune modification.
|
||||
|
||||
---
|
||||
|
||||
## Scénario 5 — Fallback "pas de CB"
|
||||
|
||||
**Objectif** : valider que l'user qui clique "Commencer en Free" peut continuer sans CB.
|
||||
|
||||
### Étapes
|
||||
|
||||
1. Signup neuf (`carol+free@rubis.test`).
|
||||
2. `/onboarding/billing` → cliquer **"Pas de carte ? Commencer en Free (2 factures)"**.
|
||||
3. L'app continue sur `/onboarding/compte` sans appel Stripe.
|
||||
|
||||
### Vérifications
|
||||
|
||||
- [ ] **DB org** : `plan='free'`, `stripe_customer_id=null`, `trial_ends_at=null`.
|
||||
- [ ] **App** : limite Free 2 factures s'applique immédiatement (importer une 3e facture → 422 `free_limit_active_invoices`).
|
||||
- [ ] **Network** : aucun appel `/billing/start-trial`.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup post-tests
|
||||
|
||||
```bash
|
||||
# Supprimer les test clocks créés
|
||||
stripe test_helpers test_clocks list
|
||||
stripe test_helpers test_clocks delete <CLOCK_ID>
|
||||
|
||||
# Supprimer les customers test (Dashboard → Customers → filtrer par metadata.test=true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cas qui restent à automatiser (V2)
|
||||
|
||||
- **Playwright headless** : drive le Checkout iframe + 3DS modal. Faisable mais coûteux à maintenir vs gain réel — la chaîne `applicative` est déjà couverte par 60 tests unitaires + 16 tests E2E HTTP. Le seul gap restant est l'**UI Stripe** elle-même, qu'on ne contrôle pas et qui change régulièrement côté Stripe.
|
||||
- **Test clock automation** : encapsuler le advance + assertion dans un helper japa réutilisable. Pertinent si on multiplie les scénarios timing.
|
||||
|
||||
Le scope V1 est : tests E2E auto sur **notre code**, playbook manuel pour **les surfaces Stripe**. Le ratio sécurité/effort est bon.
|
||||
Loading…
x
Reference in New Issue
Block a user