feat(billing): Customer Portal + page Paramètres + Standard→Premium via portal

- 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) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 05:38:36 +03:00
parent bda7feb196
commit de16deede3
8 changed files with 453 additions and 17 deletions

View file

@ -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() {
<Route path="/methodologie" element={<ComingSoon />} />
<Route path="/historique" element={<HistoriquePage />} />
<Route path="/plan" element={<PricingPage />} />
<Route path="/parametres" element={<ComingSoon />} />
<Route path="/parametres" element={<ParametresPage />} />
</Route>
{/* ── Dev only ─────────────────────────────────────────────── */}

View file

@ -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 (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<header className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Paramètres
</p>
<h1 className="mt-1 text-[32px] font-bold tracking-[-0.02em] text-ink-primary">
Mon compte
</h1>
<p className="mt-2 text-sm text-ink-secondary">
Gérez votre abonnement, vos préférences et la sécurité de votre compte.
</p>
</header>
<div className="space-y-6">
<AccountBillingSection />
<Card variant="default" className="space-y-4 p-6">
<header>
<h2 className="text-lg font-semibold text-ink-primary">Session</h2>
<p className="mt-1 text-sm text-ink-secondary">
Terminer votre session sur cet appareil.
</p>
</header>
<Button
variant="secondary"
size="md"
icon={<LogOut className="size-4" aria-hidden="true" />}
onClick={handleSignOut}
>
Se déconnecter
</Button>
</Card>
</div>
</div>
)
}

View file

@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<AccountBillingSection />
</MemoryRouter>
</QueryClientProvider>,
)
}
function mockPlan(plan: 'free' | 'standard' | 'premium') {
usePlanMock.mockReturnValue({
data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
isLoading: false,
})
}
function mockPortal(overrides: Partial<ReturnType<typeof useCustomerPortalMock>> = {}) {
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)
})
})

View file

@ -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')

View file

@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// ─── 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 dabord à 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)
})
})
})

View file

@ -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 (
<Card variant="default" className="p-6">
<div
className="h-24 animate-pulse rounded bg-surface-hover"
aria-busy="true"
aria-label="Chargement du plan…"
/>
</Card>
)
}
const plan = planData.plan as 'free' | 'standard' | 'premium'
const isSubscribed = plan !== 'free'
return (
<Card variant="default" className="space-y-4 p-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-ink-primary">Abonnement</h2>
<Badge variant="plan" planValue={plan}>
{PLAN_LABEL[plan]}
</Badge>
</header>
{isSubscribed ? (
<>
<p className="text-sm text-ink-secondary">
Modifier votre plan, mettre à jour votre moyen de paiement, ou consulter vos factures.
</p>
<Button variant="primary" size="md" onClick={openPortal} loading={isPortalLoading}>
Gérer mon abonnement
</Button>
{portalError && (
<p
role="alert"
className="rounded-md border border-danger/30 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{portalError}
</p>
)}
</>
) : (
<>
<p className="text-sm text-ink-secondary">
Vous utilisez actuellement le plan gratuit (5 simulations à vie). Découvrez les plans
payants pour un entraînement illimité avec correction détaillée.
</p>
<Button variant="primary" size="md">
<Link to="/plan" className="-m-1 p-1">
Voir les plans
</Link>
</Button>
</>
)}
</Card>
)
}

View file

@ -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` (StandardPremium) : 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 douvrir lespace abonnement. Réessayez dans quelques instants.'
export function useCustomerPortal(): UseCustomerPortalResult {
const [error, setError] = useState<string | null>(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,
}
}

View file

@ -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<PriceType | null>(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<Record<PriceType, string>> =
error && lastClicked ? { [lastClicked]: error } : {}
effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">