diff --git a/apps/web/src/components/factures/ManualInvoiceDialog.tsx b/apps/web/src/components/factures/ManualInvoiceDialog.tsx
index 05d0fb8..50a2c2f 100644
--- a/apps/web/src/components/factures/ManualInvoiceDialog.tsx
+++ b/apps/web/src/components/factures/ManualInvoiceDialog.tsx
@@ -131,8 +131,15 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP
toast.success("Facture créée. + 1 rubis.");
onOpenChange(false);
},
- onError: () => {
- toast.error("Création impossible. Vérifiez les champs.");
+ onError: (err) => {
+ // Remonte le message de l'API si on l'a (ex. quota Free atteint,
+ // duplicate, validation). Sinon toast générique.
+ // Le `ApiError` de @/lib/api expose `.message` directement.
+ const message =
+ err instanceof Error && err.message
+ ? err.message
+ : "Création impossible. Vérifiez les champs.";
+ toast.error(message);
},
});
diff --git a/apps/web/src/components/settings/AccountForm.tsx b/apps/web/src/components/settings/AccountForm.tsx
index 1fe0fd2..18fa812 100644
--- a/apps/web/src/components/settings/AccountForm.tsx
+++ b/apps/web/src/components/settings/AccountForm.tsx
@@ -118,16 +118,20 @@ export function AccountForm() {
)}
-
-
-
+ state.isDirty}>
+ {(isDirty) => (
+
+
+
+ )}
+
);
}
diff --git a/apps/web/src/components/settings/OrganizationForm.tsx b/apps/web/src/components/settings/OrganizationForm.tsx
index b2ad9ec..6b398a7 100644
--- a/apps/web/src/components/settings/OrganizationForm.tsx
+++ b/apps/web/src/components/settings/OrganizationForm.tsx
@@ -175,16 +175,20 @@ export function OrganizationForm() {
)}
-
-
-
+ state.isDirty}>
+ {(isDirty) => (
+
+
+
+ )}
+
);
}
diff --git a/apps/web/src/components/settings/SignatureForm.tsx b/apps/web/src/components/settings/SignatureForm.tsx
index 9ff11f3..2b6ca61 100644
--- a/apps/web/src/components/settings/SignatureForm.tsx
+++ b/apps/web/src/components/settings/SignatureForm.tsx
@@ -102,16 +102,20 @@ export function SignatureForm() {
)}
-
-
-
+ state.isDirty}>
+ {(isDirty) => (
+
+
+
+ )}
+
);
}
diff --git a/e2e/tests/billing-quota.spec.ts b/e2e/tests/billing-quota.spec.ts
new file mode 100644
index 0000000..41170ba
--- /dev/null
+++ b/e2e/tests/billing-quota.spec.ts
@@ -0,0 +1,151 @@
+import { test, expect } from '@playwright/test'
+import { resetDb } from './helpers/api'
+
+/**
+ * Scénario quota Free 2 factures (cf. ADR-023).
+ *
+ * Nouveau user → org en Free pure (pas de grace_period_ends_at posé sur
+ * les nouvelles orgs, cf. billing.ts §contexte). À la 3e facture active,
+ * la création doit être bloquée par `canCreateInvoices` → 402
+ * plan_limit_reached côté API.
+ *
+ * On valide ici la chaîne : SPA → API → DB. Les tests unit billing.spec.ts
+ * couvrent les variations détaillées (delta, statuts).
+ */
+
+async function signupAndOnboard(page: import('@playwright/test').Page) {
+ const email = `quota+${Date.now()}@rubis.test`
+ await page.goto('/signup')
+ await page.getByLabel(/Prénom \/ Nom/i).fill('Quota 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 })
+ await page.getByRole('button', { name: /continuer/i }).click()
+ await page.waitForURL(/\/onboarding\/entreprise/)
+ await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Quota')
+ 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 })
+}
+
+async function createInvoiceForNewClient(
+ page: import('@playwright/test').Page,
+ opts: { clientName: string; clientEmail: string; numero: string; amount: string },
+) {
+ await page.getByRole('button', { name: /^saisir$/i }).first().click()
+ await expect(page.getByRole('dialog')).toBeVisible()
+
+ const combobox = page.getByPlaceholder(/rechercher.*créer un client/i)
+ await combobox.fill(opts.clientName)
+ // Une fois 1er client créé, il apparaît dans le dropdown — on prend
+ // soit l'option "Créer le client" pour un nouveau, soit l'option
+ // matchée pour un existant.
+ const createOption = page.getByRole('button', {
+ name: new RegExp(`créer le client.*${opts.clientName}`, 'i'),
+ })
+ const existingOption = page.getByRole('option', {
+ name: new RegExp(opts.clientName, 'i'),
+ })
+
+ // Race : on prend ce qui apparaît en premier
+ await Promise.race([
+ createOption.waitFor({ state: 'visible', timeout: 3_000 }).then(() => createOption.click()),
+ existingOption.waitFor({ state: 'visible', timeout: 3_000 }).then(() => existingOption.click()),
+ ])
+
+ // Le champ email apparaît si c'est un nouveau client
+ const emailField = page.getByLabel(/email du client/i)
+ if (await emailField.isVisible().catch(() => false)) {
+ await emailField.fill(opts.clientEmail)
+ }
+
+ await page.getByLabel(/N° de facture/i).fill(opts.numero)
+ await page.getByLabel(/Montant TTC/i).fill(opts.amount)
+ await page.getByLabel(/Plan de relance/i).click()
+ await page.getByRole('option').first().click()
+ await page.getByRole('button', { name: /^créer la facture$/i }).click()
+}
+
+test.describe('Billing — quota Free 2 factures', () => {
+ test.beforeEach(async () => {
+ await resetDb()
+ })
+
+ test('Free post-grace : 2 factures OK, la 3e est bloquée avec message clair', async ({
+ page,
+ }) => {
+ await signupAndOnboard(page)
+
+ // 1re facture OK
+ await createInvoiceForNewClient(page, {
+ clientName: 'Client Quota A',
+ clientEmail: 'a@quota.test',
+ numero: 'F-QUOTA-1',
+ amount: '100',
+ })
+ await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 })
+
+ // 2e OK (on a maintenant 2 factures actives sur 2)
+ await createInvoiceForNewClient(page, {
+ clientName: 'Client Quota B',
+ clientEmail: 'b@quota.test',
+ numero: 'F-QUOTA-2',
+ amount: '200',
+ })
+ await expect(page.getByText(/facture créée/i).first()).toBeVisible({
+ timeout: 5_000,
+ })
+
+ // 3e doit être bloquée (Free 2 max, post-grace)
+ await createInvoiceForNewClient(page, {
+ clientName: 'Client Quota C',
+ clientEmail: 'c@quota.test',
+ numero: 'F-QUOTA-3',
+ amount: '300',
+ })
+
+ // Toast d'erreur "Limite atteinte : 2 factures actives sur le plan Free"
+ await expect(
+ page.getByText(/limite.*atteinte|2 factures.*Free|passez pro/i).first(),
+ ).toBeVisible({ timeout: 5_000 })
+
+ // La facture F-QUOTA-3 ne doit PAS apparaître dans /factures
+ await page.goto('/factures')
+ await expect(page.getByText('F-QUOTA-1').first()).toBeVisible()
+ await expect(page.getByText('F-QUOTA-2').first()).toBeVisible()
+ await expect(page.getByText('F-QUOTA-3')).not.toBeVisible()
+ })
+
+ test('Banner "Limite Free atteinte" apparaît sur /factures à 2/2', async ({
+ page,
+ }) => {
+ await signupAndOnboard(page)
+
+ // Créer 2 factures pour atteindre la limite
+ await createInvoiceForNewClient(page, {
+ clientName: 'Banner A',
+ clientEmail: 'banner-a@quota.test',
+ numero: 'F-BAN-1',
+ amount: '100',
+ })
+ await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 })
+
+ await createInvoiceForNewClient(page, {
+ clientName: 'Banner B',
+ clientEmail: 'banner-b@quota.test',
+ numero: 'F-BAN-2',
+ amount: '200',
+ })
+ await expect(page.getByText(/facture créée/i).first()).toBeVisible({
+ timeout: 5_000,
+ })
+
+ // Sur /factures, le banner doit afficher "Limite Free atteinte"
+ await page.goto('/factures')
+ await expect(
+ page.getByText(/limite Free atteinte|Passer Pro/i).first(),
+ ).toBeVisible({ timeout: 5_000 })
+ })
+})
diff --git a/e2e/tests/billing-states.spec.ts b/e2e/tests/billing-states.spec.ts
new file mode 100644
index 0000000..d966d4d
--- /dev/null
+++ b/e2e/tests/billing-states.spec.ts
@@ -0,0 +1,141 @@
+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)
+ })
+})
diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts
new file mode 100644
index 0000000..321e620
--- /dev/null
+++ b/e2e/tests/dashboard.spec.ts
@@ -0,0 +1,85 @@
+import { test, expect } from '@playwright/test'
+import { resetDb } from './helpers/api'
+
+/**
+ * Scénarios dashboard : KPIs à zéro au start, bump après actions.
+ *
+ * Les calculs des KPIs sont testés par les tests japa functional
+ * (dashboard.spec.ts) — ici on valide la chaîne SPA → API → render
+ * sur la page d'accueil.
+ */
+
+async function signupAndOnboard(page: import('@playwright/test').Page) {
+ const email = `dash+${Date.now()}@rubis.test`
+ await page.goto('/signup')
+ await page.getByLabel(/Prénom \/ Nom/i).fill('Dashboard 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 })
+ await page.getByRole('button', { name: /continuer/i }).click()
+ await page.waitForURL(/\/onboarding\/entreprise/)
+ await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Dashboard')
+ 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 })
+}
+
+test.describe('Dashboard', () => {
+ test.beforeEach(async () => {
+ await resetDb()
+ })
+
+ test('zéros au start : 0 rubis, "Pas encore d\'activité"', async ({ page }) => {
+ await signupAndOnboard(page)
+ await page.goto('/')
+
+ // RubisHero : "0 rubis gagnés ce mois"
+ await expect(page.getByText(/0\s+rubis/i).first()).toBeVisible({ timeout: 10_000 })
+
+ // Activity feed vide
+ await expect(
+ page.getByText(/pas encore d'activité|importez votre première facture/i).first(),
+ ).toBeVisible()
+ })
+
+ test('après mark-paid : rubis += 1 + activity contient l\'event', async ({
+ page,
+ }) => {
+ await signupAndOnboard(page)
+
+ // 1. Créer client + facture
+ await page.getByRole('button', { name: /^saisir$/i }).first().click()
+ const combobox = page.getByPlaceholder(/rechercher.*créer un client/i)
+ await combobox.fill('Dashboard Client')
+ await page.getByRole('button', { name: /créer le client.*dashboard client/i }).click()
+ await page.getByLabel(/email du client/i).fill('client@dashboard.test')
+ await page.getByLabel(/N° de facture/i).fill('F-DASH-001')
+ await page.getByLabel(/Montant TTC/i).fill('500')
+ await page.getByLabel(/Plan de relance/i).click()
+ await page.getByRole('option').first().click()
+ await page.getByRole('button', { name: /^créer la facture$/i }).click()
+ await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 })
+
+ // 2. Aller sur la facture, mark-paid
+ await page.goto('/factures')
+ await page.getByText('F-DASH-001').first().click()
+ await page.getByRole('button', { name: /marquer encaissée/i }).click()
+ await expect(page.getByText(/encaissée.*rubis/i)).toBeVisible({ timeout: 5_000 })
+
+ // 3. Sur le dashboard, rubis count = 1 (création) + 1 (mark-paid) = 2 rubis
+ // Note : la création d'une facture saisie manuelle pose rubisEarned=1
+ // et le mark-paid bump encore +1
+ await page.goto('/')
+ // Le compteur RubisHero affiche "X rubis gagnés" et la sidebar aussi
+ await expect(page.getByText(/[1-2]\s+rubis/i).first()).toBeVisible({
+ timeout: 10_000,
+ })
+
+ // 4. Activity feed contient l'event "Facture F-DASH-001 marquée encaissée"
+ await expect(page.getByText(/F-DASH-001/).first()).toBeVisible({
+ timeout: 5_000,
+ })
+ })
+})
diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts
new file mode 100644
index 0000000..d032bc1
--- /dev/null
+++ b/e2e/tests/settings.spec.ts
@@ -0,0 +1,74 @@
+import { test, expect } from '@playwright/test'
+import { resetDb } from './helpers/api'
+
+/**
+ * Scénarios /parametres : forms account + organization + signature.
+ *
+ * Chaque form est isolé (son propre Save button). On valide la
+ * persistence après reload page — pas juste l'UI optimiste.
+ */
+
+async function signupAndOnboard(page: import('@playwright/test').Page) {
+ const email = `set+${Date.now()}@rubis.test`
+ await page.goto('/signup')
+ await page.getByLabel(/Prénom \/ Nom/i).fill('Settings 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 })
+ await page.getByRole('button', { name: /continuer/i }).click()
+ await page.waitForURL(/\/onboarding\/entreprise/)
+ await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Settings')
+ 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 })
+}
+
+test.describe('Settings', () => {
+ test.beforeEach(async () => {
+ await resetDb()
+ })
+
+ test('page /parametres affiche les sections principales', async ({ page }) => {
+ await signupAndOnboard(page)
+ await page.goto('/parametres')
+
+ await expect(page.getByRole('heading', { name: /paramètres/i })).toBeVisible()
+
+ // Les sections AccountForm, OrganizationForm, SignatureForm sont
+ // toutes visibles (rendues par /parametres). Le scroll est long mais
+ // tous les labels sont dans le DOM dès le mount.
+ await expect(page.getByText(/^compte$/i).first()).toBeVisible()
+ await expect(page.getByText(/^entreprise$/i).first()).toBeVisible()
+ await expect(page.getByText(/signature email/i).first()).toBeVisible()
+ })
+
+ test('modifier "Prénom et nom" → toast + persistence après reload', async ({
+ page,
+ }) => {
+ await signupAndOnboard(page)
+ await page.goto('/parametres')
+
+ const fullNameInput = page.getByLabel(/^Prénom et nom$/i)
+ await expect(fullNameInput).toBeVisible({ timeout: 5_000 })
+
+ // L'initial du onboarding était "Settings Test"
+ await expect(fullNameInput).toHaveValue('Settings Test')
+
+ await fullNameInput.fill('Pierre Modifié')
+
+ // Le bouton passe de "Aucune modification" à "Enregistrer"
+ const saveButton = page.getByRole('button', { name: /^enregistrer$/i }).first()
+ await saveButton.click()
+
+ // Confirmation : le bouton revient à "Aucune modification" après save
+ await expect(
+ page.getByRole('button', { name: /aucune modification/i }).first(),
+ ).toBeVisible({ timeout: 5_000 })
+
+ // Reload : la valeur persiste
+ await page.reload()
+ await expect(page.getByLabel(/^Prénom et nom$/i)).toHaveValue('Pierre Modifié')
+ })
+})