All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 28s
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>
142 lines
4.8 KiB
TypeScript
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)
|
|
})
|
|
})
|