From de16deede34623dc960a74556c2169c48d4d83bb Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 26 Apr 2026 05:38:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(billing):=20Customer=20Portal=20+=20page?= =?UTF-8?q?=20Param=C3=A8tres=20+=20Standard=E2=86=92Premium=20via=20porta?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCustomerPortal hook (mutation + redirect full-page) - AccountBillingSection: badge plan + bouton Gérer mon abonnement (Standard/Premium) - ParametresPage: page conteneur /parametres avec section billing - PricingPage: Standard→Premium redirige vers Customer Portal (prorata natif Stripe) - Tests: 212 → 219 verts (+7) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/router.tsx | 3 +- src/features/account/pages/ParametresPage.tsx | 67 +++++++++++++ .../__tests__/AccountBillingSection.test.tsx | 99 +++++++++++++++++++ .../billing/__tests__/PricingPage.test.tsx | 27 ++++- .../__tests__/useCustomerPortal.test.tsx | 97 ++++++++++++++++++ .../components/AccountBillingSection.tsx | 86 ++++++++++++++++ .../billing/hooks/useCustomerPortal.ts | 55 +++++++++++ src/features/billing/pages/PricingPage.tsx | 36 ++++--- 8 files changed, 453 insertions(+), 17 deletions(-) create mode 100644 src/features/account/pages/ParametresPage.tsx create mode 100644 src/features/billing/__tests__/AccountBillingSection.test.tsx create mode 100644 src/features/billing/__tests__/useCustomerPortal.test.tsx create mode 100644 src/features/billing/components/AccountBillingSection.tsx create mode 100644 src/features/billing/hooks/useCustomerPortal.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index 5b0c693..1bde52d 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -18,6 +18,7 @@ import { PresentationGenereeT1Page } from '@/features/simulations/pages/Presenta import { HistoriquePage } from '@/features/historique/pages/HistoriquePage' import { ProgressionPage } from '@/features/progression/pages/ProgressionPage' import { PricingPage } from '@/features/billing/pages/PricingPage' +import { ParametresPage } from '@/features/account/pages/ParametresPage' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' import { AppLayout } from './AppLayout' @@ -91,7 +92,7 @@ export function AppRouter() { } /> } /> } /> - } /> + } /> {/* ── Dev only ─────────────────────────────────────────────── */} diff --git a/src/features/account/pages/ParametresPage.tsx b/src/features/account/pages/ParametresPage.tsx new file mode 100644 index 0000000..4499239 --- /dev/null +++ b/src/features/account/pages/ParametresPage.tsx @@ -0,0 +1,67 @@ +/** + * Page Paramètres — Sprint 5d. + * + * Page minimale conteneur pour les sections de gestion du compte : + * - Abonnement (`AccountBillingSection`) — Stripe. + * - Session — bouton de déconnexion. + * Future : préférences langue, sécurité (changement mot de passe), + * suppression compte, etc. + * + * Wrapper layout standard 1100px (cohérent avec convention Sprint 4.7). + */ + +import { useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { LogOut } from 'lucide-react' +import { Button } from '@/shared/ui/Button' +import { Card } from '@/shared/ui/Card' +import { signOut } from '@/shared/lib/auth-client' +import { AccountBillingSection } from '@/features/billing/components/AccountBillingSection' + +export function ParametresPage() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + async function handleSignOut() { + await signOut() + queryClient.clear() + navigate('/login', { replace: true }) + } + + return ( +
+
+

+ Paramètres +

+

+ Mon compte +

+

+ Gérez votre abonnement, vos préférences et la sécurité de votre compte. +

+
+ +
+ + + +
+

Session

+

+ Terminer votre session sur cet appareil. +

+
+ +
+
+
+ ) +} diff --git a/src/features/billing/__tests__/AccountBillingSection.test.tsx b/src/features/billing/__tests__/AccountBillingSection.test.tsx new file mode 100644 index 0000000..c76b604 --- /dev/null +++ b/src/features/billing/__tests__/AccountBillingSection.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const { usePlanMock, useCustomerPortalMock } = vi.hoisted(() => ({ + usePlanMock: vi.fn(), + useCustomerPortalMock: vi.fn(), +})) + +vi.mock('@/features/dashboard/hooks/usePlan', () => ({ + usePlan: usePlanMock, +})) + +vi.mock('../hooks/useCustomerPortal', () => ({ + useCustomerPortal: useCustomerPortalMock, +})) + +import { AccountBillingSection } from '../components/AccountBillingSection' + +afterEach(() => { + cleanup() + usePlanMock.mockReset() + useCustomerPortalMock.mockReset() +}) + +function renderSection() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +function mockPlan(plan: 'free' | 'standard' | 'premium') { + usePlanMock.mockReturnValue({ + data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null }, + isLoading: false, + }) +} + +function mockPortal(overrides: Partial> = {}) { + const openPortal = vi.fn() + useCustomerPortalMock.mockReturnValue({ + openPortal, + isLoading: false, + error: null, + ...overrides, + }) + return openPortal +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('AccountBillingSection — plan free', () => { + it('affiche le badge Découverte + lien "Voir les plans" → /plan', () => { + mockPlan('free') + mockPortal() + renderSection() + + expect(screen.getByText('Plan Découverte')).toBeInTheDocument() + const link = screen.getByRole('link', { name: /voir les plans/i }) + expect(link).toHaveAttribute('href', '/plan') + expect(screen.queryByRole('button', { name: /gérer mon abonnement/i })).not.toBeInTheDocument() + }) +}) + +describe('AccountBillingSection — plan standard', () => { + it('clic sur "Gérer mon abonnement" appelle openPortal', async () => { + const user = userEvent.setup() + mockPlan('standard') + const openPortal = mockPortal() + renderSection() + + expect(screen.getByText('Plan Standard')).toBeInTheDocument() + const btn = screen.getByRole('button', { name: /gérer mon abonnement/i }) + expect(btn).toBeEnabled() + + await user.click(btn) + expect(openPortal).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('link', { name: /voir les plans/i })).not.toBeInTheDocument() + }) + + it("affiche un callout d'erreur quand le hook expose error", () => { + mockPlan('premium') + mockPortal({ error: 'Aucun abonnement actif trouvé.' }) + renderSection() + + expect(screen.getByRole('alert')).toHaveTextContent(/Aucun abonnement actif trouvé/i) + }) +}) diff --git a/src/features/billing/__tests__/PricingPage.test.tsx b/src/features/billing/__tests__/PricingPage.test.tsx index 1f97047..fa076fc 100644 --- a/src/features/billing/__tests__/PricingPage.test.tsx +++ b/src/features/billing/__tests__/PricingPage.test.tsx @@ -6,10 +6,13 @@ import { MemoryRouter } from 'react-router-dom' // ─── Mocks ─────────────────────────────────────────────────────────────────── -const { usePlanMock, createCheckoutSessionMock } = vi.hoisted(() => ({ - usePlanMock: vi.fn(), - createCheckoutSessionMock: vi.fn(), -})) +const { usePlanMock, createCheckoutSessionMock, createCustomerPortalSessionMock } = vi.hoisted( + () => ({ + usePlanMock: vi.fn(), + createCheckoutSessionMock: vi.fn(), + createCustomerPortalSessionMock: vi.fn(), + }), +) vi.mock('@/features/dashboard/hooks/usePlan', () => ({ usePlan: usePlanMock, @@ -17,6 +20,7 @@ vi.mock('@/features/dashboard/hooks/usePlan', () => ({ vi.mock('../api', () => ({ createCheckoutSession: createCheckoutSessionMock, + createCustomerPortalSession: createCustomerPortalSessionMock, })) import { PricingPage } from '../pages/PricingPage' @@ -25,6 +29,7 @@ afterEach(() => { cleanup() usePlanMock.mockReset() createCheckoutSessionMock.mockReset() + createCustomerPortalSessionMock.mockReset() }) function renderPage() { @@ -120,6 +125,20 @@ describe('PricingPage — interaction', () => { expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard') }) + it('Standard user clique "Passer en Premium" → createCustomerPortalSession (PAS createCheckoutSession)', async () => { + const user = userEvent.setup() + mockPlan('standard') + createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {})) + + renderPage() + await user.click(screen.getByRole('button', { name: /Passer en Premium$/ })) + + await waitFor(() => { + expect(createCustomerPortalSessionMock).toHaveBeenCalledTimes(1) + }) + expect(createCheckoutSessionMock).not.toHaveBeenCalled() + }) + it("erreur de mutation → callout d'erreur affiché", async () => { const user = userEvent.setup() mockPlan('free') diff --git a/src/features/billing/__tests__/useCustomerPortal.test.tsx b/src/features/billing/__tests__/useCustomerPortal.test.tsx new file mode 100644 index 0000000..4e510fd --- /dev/null +++ b/src/features/billing/__tests__/useCustomerPortal.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const { createCustomerPortalSessionMock } = vi.hoisted(() => ({ + createCustomerPortalSessionMock: vi.fn(), +})) + +vi.mock('../api', () => ({ + createCustomerPortalSession: createCustomerPortalSessionMock, +})) + +import { useCustomerPortal } from '../hooks/useCustomerPortal' + +afterEach(() => { + createCustomerPortalSessionMock.mockReset() +}) + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return {children} +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('useCustomerPortal', () => { + it("openPortal() succès → window.location.href set sur l'URL portal", async () => { + const originalLocation = window.location + const hrefSetter = vi.fn() + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { + ...originalLocation, + get href() { + return originalLocation.href + }, + set href(v: string) { + hrefSetter(v) + }, + }, + }) + + createCustomerPortalSessionMock.mockResolvedValue({ + url: 'https://billing.stripe.com/p/session/abc', + }) + + const { result } = renderHook(() => useCustomerPortal(), { wrapper }) + act(() => { + result.current.openPortal() + }) + + await waitFor(() => { + expect(hrefSetter).toHaveBeenCalledWith('https://billing.stripe.com/p/session/abc') + }) + + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation, + }) + }) + + it('erreur backend → message backend propagé dans `error`', async () => { + createCustomerPortalSessionMock.mockRejectedValue( + new Error('Aucun abonnement actif trouvé. Souscrivez d’abord à un plan.'), + ) + const { result } = renderHook(() => useCustomerPortal(), { wrapper }) + + act(() => { + result.current.openPortal() + }) + + await waitFor(() => { + expect(result.current.error).toMatch(/Aucun abonnement actif trouvé/) + }) + }) + + it('isLoading vrai pendant la mutation pending', async () => { + createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {})) + const { result } = renderHook(() => useCustomerPortal(), { wrapper }) + + expect(result.current.isLoading).toBe(false) + act(() => { + result.current.openPortal() + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + }) +}) diff --git a/src/features/billing/components/AccountBillingSection.tsx b/src/features/billing/components/AccountBillingSection.tsx new file mode 100644 index 0000000..535d5f5 --- /dev/null +++ b/src/features/billing/components/AccountBillingSection.tsx @@ -0,0 +1,86 @@ +/** + * Sprint 5d — Section Abonnement de la page Paramètres. + * + * Affiche le plan actuel + un CTA contextuel : + * - Plan free → lien « Voir les plans » vers `/plan`. + * - Plan payant → bouton « Gérer mon abonnement » → Stripe Customer Portal. + * + * Règle D : aucune comparaison `plan === 'xxx'` exposée hors d'un mapping + * explicite (ici la branche est binaire free vs payant). + * Règle L : tokens DA Charcoal exclusivement. + */ + +import { Link } from 'react-router-dom' +import { Button } from '@/shared/ui/Button' +import { Card } from '@/shared/ui/Card' +import { Badge } from '@/shared/ui/Badge' +import { usePlan } from '@/features/dashboard/hooks/usePlan' +import { useCustomerPortal } from '../hooks/useCustomerPortal' + +const PLAN_LABEL: Record<'free' | 'standard' | 'premium', string> = { + free: 'Plan Découverte', + standard: 'Plan Standard', + premium: 'Plan Premium', +} + +export function AccountBillingSection() { + const { data: planData, isLoading: isPlanLoading } = usePlan() + const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal() + + if (isPlanLoading || !planData) { + return ( + +
+ + ) + } + + const plan = planData.plan as 'free' | 'standard' | 'premium' + const isSubscribed = plan !== 'free' + + return ( + +
+

Abonnement

+ + {PLAN_LABEL[plan]} + +
+ + {isSubscribed ? ( + <> +

+ Modifier votre plan, mettre à jour votre moyen de paiement, ou consulter vos factures. +

+ + {portalError && ( +

+ {portalError} +

+ )} + + ) : ( + <> +

+ Vous utilisez actuellement le plan gratuit (5 simulations à vie). Découvrez les plans + payants pour un entraînement illimité avec correction détaillée. +

+ + + )} +
+ ) +} diff --git a/src/features/billing/hooks/useCustomerPortal.ts b/src/features/billing/hooks/useCustomerPortal.ts new file mode 100644 index 0000000..c04b6cf --- /dev/null +++ b/src/features/billing/hooks/useCustomerPortal.ts @@ -0,0 +1,55 @@ +/** + * Sprint 5d — Hook Stripe Customer Portal. + * + * Wrap la mutation `createCustomerPortalSession` + redirect full-page vers + * la session Customer Portal Stripe. Utilisé par : + * - `AccountBillingSection` (page Paramètres) : bouton « Gérer mon abonnement ». + * - `PricingPage` (Standard→Premium) : redirige vers le portal qui gère + * nativement l'upgrade prorata + confirmation du montant. + * + * Erreur 400 `NO_ACTIVE_SUBSCRIPTION` propagée telle quelle (le backend + * fournit déjà un message FR exploitable directement). + */ + +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { createCustomerPortalSession } from '../api' + +export interface UseCustomerPortalResult { + openPortal: () => void + isLoading: boolean + error: string | null +} + +const FALLBACK_ERROR_MESSAGE = + 'Impossible d’ouvrir l’espace abonnement. Réessayez dans quelques instants.' + +export function useCustomerPortal(): UseCustomerPortalResult { + const [error, setError] = useState(null) + + const mutation = useMutation({ + mutationFn: createCustomerPortalSession, + onSuccess: (data) => { + // Redirect full-page : l'utilisateur reviendra sur ${APP_URL}/dashboard + // (cf. backend `return_url`). Le query param `?upgrade=success` n'est PAS + // ajouté par le portal — pas de banner de bienvenue dans ce flow, + // seulement une éventuelle invalidation au refresh manuel. + window.location.href = data.url + }, + onError: (err: unknown) => { + const message = err instanceof Error && err.message ? err.message : FALLBACK_ERROR_MESSAGE + setError(message) + }, + }) + + function openPortal(): void { + setError(null) + mutation.mutate() + } + + return { + openPortal, + isLoading: mutation.isPending, + error, + } +} diff --git a/src/features/billing/pages/PricingPage.tsx b/src/features/billing/pages/PricingPage.tsx index 973298a..326aadc 100644 --- a/src/features/billing/pages/PricingPage.tsx +++ b/src/features/billing/pages/PricingPage.tsx @@ -20,6 +20,7 @@ import { useState } from 'react' import { usePlan } from '@/features/dashboard/hooks/usePlan' import { type PriceType } from '../api' import { useStripeCheckout } from '../hooks/useStripeCheckout' +import { useCustomerPortal } from '../hooks/useCustomerPortal' import { PlanCard, type PlanCardCta } from '../components/PlanCard' type Plan = 'free' | 'standard' | 'premium' @@ -87,12 +88,11 @@ interface CtaConfigs { function buildCtaConfigs( plan: Plan, - pendingType: PriceType | null, + isStandardPending: boolean, + isPremiumPending: boolean, onUpgrade: (priceType: PriceType) => void, ): CtaConfigs { - const isStandardPending = pendingType === 'standard' - const isPremiumPending = pendingType === 'premium' - const anyPending = pendingType !== null + const anyPending = isStandardPending || isPremiumPending if (plan === 'free') { return { @@ -148,21 +148,33 @@ function buildCtaConfigs( export function PricingPage() { const { data: planData, isLoading } = usePlan() - const { checkout, pendingPriceType, error } = useStripeCheckout() - // Mémorise le dernier priceType cliqué pour rattacher l'erreur globale du - // hook à la bonne carte. Sprint 5c — `useStripeCheckout` n'expose qu'un - // `error` global (l'utilisateur ne clique qu'un CTA à la fois). + const { checkout, pendingPriceType, error: checkoutError } = useStripeCheckout() + const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal() + // Mémorise le dernier priceType cliqué pour rattacher l'erreur (checkout OU + // portal) à la bonne carte. L'utilisateur ne clique qu'un CTA à la fois. const [lastClicked, setLastClicked] = useState(null) + const plan = (planData?.plan as Plan | undefined) ?? 'free' + + // Sprint 5d — branche Standard→Premium via Customer Portal (Stripe affiche + // le montant prorata + confirmation native). Free→* reste sur Checkout direct. function handleUpgrade(priceType: PriceType) { setLastClicked(priceType) - checkout(priceType) + if (plan === 'standard') { + openPortal() + } else { + checkout(priceType) + } } - const plan = (planData?.plan as Plan | undefined) ?? 'free' - const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade) + // Loading par carte : combine la source pertinente selon le plan utilisateur. + const isStandardPending = pendingPriceType === 'standard' + const isPremiumPending = plan === 'standard' ? isPortalLoading : pendingPriceType === 'premium' + + const ctaConfigs = buildCtaConfigs(plan, isStandardPending, isPremiumPending, handleUpgrade) + const effectiveError = checkoutError ?? portalError const errorByType: Partial> = - error && lastClicked ? { [lastClicked]: error } : {} + effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {} return (