rubis/e2e/tests/billing-states.spec.ts
ordinarthur e40f417caa
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 28s
test(e2e): PR 3 — billing states + quota + dashboard + settings (+ fix isDirty)
Couvre les surfaces transverses post auth/clients/factures :

- billing-states (3) : transitions webhook trial→active, past_due, cancel
- billing-quota (2) : Free limite à 2 factures actives, 3e bloquée + toast
  remonté avec message API (UX bug : onError du dialog masquait l'erreur)
- dashboard (2) : zéros au start, +rubis et activity feed après mark-paid
- settings (2) : sections visibles + persistence Prénom/Nom après reload

Bug isDirty détecté par TDD sur settings : AccountForm/OrganizationForm/
SignatureForm lisaient form.state.isDirty *hors* d'un form.Subscribe, donc
le bouton Save ne réagissait jamais aux changements (texte figé sur "Aucune
modification"). Fix : wrap le bouton dans form.Subscribe selector=isDirty,
même pattern que ManualInvoiceDialog.

36 tests Playwright vert, ~1m20.

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

142 lines
4.8 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { fireStripeWebhook, getLastOrg, resetDb } from './helpers/api'
/**
* Scénarios billing — transitions de subscription côté DB après les
* différents webhooks Stripe (au-delà de l'essai 14 j déjà couvert
* dans billing-trial.spec.ts).
*
* On teste les états observables :
* - subscription.updated trialing → active (J+14 paiement OK)
* - subscription.updated trialing → past_due (paiement échoué)
* - subscription.deleted → org bascule en Free
* - invoice.payment_failed → past_due
*
* Le rendu SPA du status est testé séparément par les tests vitest
* (useSubscription / SubscriptionState).
*/
async function signupAndStartTrial(page: import('@playwright/test').Page, tag: string) {
const email = `${tag}+${Date.now()}@rubis.test`
await page.goto('/signup')
await page.getByLabel(/Prénom \/ Nom/i).fill('Test')
await page.getByLabel(/Email professionnel/i).fill(email)
await page.getByLabel(/Mot de passe/i).fill('motdepasse-fort-123')
await page.getByRole('button', { name: /créer mon compte/i }).click()
await page.waitForURL(/\/onboarding\/compte/, { timeout: 10_000 })
// Onboarding pour avoir un user complet
await page.getByRole('button', { name: /continuer/i }).click()
await page.waitForURL(/\/onboarding\/entreprise/)
await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Billing')
await page.getByRole('button', { name: /continuer/i }).click()
await page.waitForURL(/\/onboarding\/signature/)
await page.getByRole('button', { name: /terminer/i }).click()
await page.waitForURL('/', { timeout: 10_000 })
// Démarrer l'essai 14 j (pose stripeCustomerId + envoie redirect)
await page.goto('/onboarding/billing')
await page.getByRole('button', { name: /démarrer mon essai 14 jours/i }).click()
await page.waitForURL(/\/onboarding\/compte\?trial=started/, { timeout: 10_000 })
// Webhook checkout.completed → org en trialing avec sub_e2e_mock posé
const org = await getLastOrg()
await fireStripeWebhook({
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_e2e_mock',
subscription: 'sub_e2e_mock',
metadata: { organization_id: org!.id },
},
},
})
return org!
}
test.describe('Billing — transitions de subscription via webhooks', () => {
test.beforeEach(async () => {
await resetDb()
})
test('trial → active : org reste pro avec status active', async ({ page }) => {
const orgBefore = await signupAndStartTrial(page, 'bill-active')
expect((await getLastOrg())?.subscription_status).toBe('trialing')
// Stripe envoie customer.subscription.updated (status='active') à J+14
await fireStripeWebhook({
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_e2e_mock',
customer: 'cus_e2e_mock',
status: 'active',
items: {
data: [
{
id: 'si_e2e',
price: { id: 'price_pro_monthly_e2e', lookup_key: 'rubis_pro_monthly' },
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 3600,
},
],
},
cancel_at_period_end: false,
metadata: { organization_id: orgBefore.id },
},
},
})
const after = await getLastOrg()
expect(after?.subscription_status).toBe('active')
expect(after?.plan).toBe('pro')
})
test('invoice.payment_failed → org passe en past_due (le plan reste Pro)', async ({
page,
}) => {
await signupAndStartTrial(page, 'bill-pastdue')
await fireStripeWebhook({
type: 'invoice.payment_failed',
data: {
object: {
id: 'in_e2e_failed',
customer: 'cus_e2e_mock',
status: 'open',
},
},
})
const after = await getLastOrg()
expect(after?.subscription_status).toBe('past_due')
// Important : le plan reste Pro (Stripe smart retries = 7 j de tolérance)
expect(after?.plan).toBe('pro')
})
test('subscription.deleted → org bascule en Free, trial_ends_at conservé', async ({
page,
}) => {
const orgBefore = await signupAndStartTrial(page, 'bill-deleted')
const trialEndsAtBefore = (await getLastOrg())?.trial_ends_at
await fireStripeWebhook({
type: 'customer.subscription.deleted',
data: {
object: {
id: 'sub_e2e_mock',
customer: 'cus_e2e_mock',
},
},
})
const after = await getLastOrg()
expect(after?.id).toBe(orgBefore.id)
expect(after?.plan).toBe('free')
expect(after?.subscription_status).toBe('canceled')
expect(after?.stripe_subscription_id).toBeNull()
// trial_ends_at conservé pour l'historique (empêche un re-trial)
expect(after?.trial_ends_at).toBeTruthy()
expect(after?.trial_ends_at).toBe(trialEndsAtBefore)
})
})