diff --git a/apps/api/.env.test b/apps/api/.env.test index 0f799cf..7c55696 100644 --- a/apps/api/.env.test +++ b/apps/api/.env.test @@ -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 diff --git a/apps/api/tests/functional/billing_trial.spec.ts b/apps/api/tests/functional/billing_trial.spec.ts new file mode 100644 index 0000000..c59edbd --- /dev/null +++ b/apps/api/tests/functional/billing_trial.spec.ts @@ -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_). */ + 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) => ({ + 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>(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>(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>(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) + }) +}) diff --git a/docs/tech/stripe-trial-e2e-playbook.md b/docs/tech/stripe-trial-e2e-playbook.md new file mode 100644 index 0000000..68585af --- /dev/null +++ b/docs/tech/stripe-trial-e2e-playbook.md @@ -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 = '';` + - `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:` 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 + +# 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.