feat(billing): useStripeCheckout hook + post-redirect upgrade success

- useStripeCheckout: mutation + redirect full-page, pendingPriceType exposed
- PricingPage migré vers useStripeCheckout (suppression useMutation inline)
- useUpgradeSuccessHandler: détecte ?upgrade=success, invalide plan cache, clean URL
- UpgradeSuccessBanner: callout success dans DashboardPage
- Tests: 203 → 212 verts (+9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 05:19:18 +03:00
parent 9edfbb3c95
commit bda7feb196
7 changed files with 371 additions and 25 deletions

View file

@ -0,0 +1,107 @@
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 { createCheckoutSessionMock } = vi.hoisted(() => ({
createCheckoutSessionMock: vi.fn(),
}))
vi.mock('../api', () => ({
createCheckoutSession: createCheckoutSessionMock,
}))
import { useStripeCheckout } from '../hooks/useStripeCheckout'
afterEach(() => {
createCheckoutSessionMock.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('useStripeCheckout', () => {
it('checkout(priceType) appelle createCheckoutSession avec le bon argument', async () => {
createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
act(() => {
result.current.checkout('standard')
})
await waitFor(() => {
expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1)
})
expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
})
it('expose pendingPriceType pendant la mutation', async () => {
createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
expect(result.current.pendingPriceType).toBeNull()
act(() => {
result.current.checkout('premium')
})
expect(result.current.pendingPriceType).toBe('premium')
})
it("redirige window.location.href vers l'URL Stripe au succès", 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)
},
},
})
createCheckoutSessionMock.mockResolvedValue({
url: 'https://checkout.stripe.com/pay/cs_xyz',
})
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
act(() => {
result.current.checkout('standard')
})
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith('https://checkout.stripe.com/pay/cs_xyz')
})
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
})
it("expose error et reset pendingPriceType en cas d'échec", async () => {
createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.'))
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
act(() => {
result.current.checkout('standard')
})
await waitFor(() => {
expect(result.current.error).toMatch(/Configuration Stripe manquante/)
})
expect(result.current.pendingPriceType).toBeNull()
})
})

View file

@ -0,0 +1,59 @@
/**
* Sprint 5c Hook checkout Stripe.
*
* Encapsule la mutation `createCheckoutSession` + la redirection full-page
* vers Stripe Checkout. Expose `pendingPriceType` pour permettre aux pages
* (ex. PricingPage) d'afficher un loading par carte sans state local.
*
* Usage typique :
* const { checkout, pendingPriceType, error } = useStripeCheckout()
* <Button loading={pendingPriceType === 'standard'} onClick={() => checkout('standard')}>
* Choisir Standard
* </Button>
*/
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { createCheckoutSession, type PriceType } from '../api'
export interface UseStripeCheckoutResult {
checkout: (priceType: PriceType) => void
isLoading: boolean
pendingPriceType: PriceType | null
error: string | null
}
const FALLBACK_ERROR_MESSAGE =
'Impossible de démarrer le paiement. Réessayez dans quelques instants.'
export function useStripeCheckout(): UseStripeCheckoutResult {
const [pendingPriceType, setPendingPriceType] = useState<PriceType | null>(null)
const [error, setError] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: createCheckoutSession,
onSuccess: (data) => {
// Redirection full-page vers Stripe Checkout. L'utilisateur reviendra
// sur /dashboard?upgrade=success après paiement réussi (cf. backend
// success_url) ou /plan?upgrade=cancelled en cas d'annulation.
window.location.href = data.url
},
onError: (err: Error) => {
setError(err.message || FALLBACK_ERROR_MESSAGE)
setPendingPriceType(null)
},
})
function checkout(priceType: PriceType): void {
setError(null)
setPendingPriceType(priceType)
mutation.mutate(priceType)
}
return {
checkout,
isLoading: mutation.isPending,
pendingPriceType,
error,
}
}

View file

@ -17,9 +17,9 @@
*/
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { createCheckoutSession, type PriceType } from '../api'
import { type PriceType } from '../api'
import { useStripeCheckout } from '../hooks/useStripeCheckout'
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
type Plan = 'free' | 'standard' | 'premium'
@ -148,34 +148,21 @@ function buildCtaConfigs(
export function PricingPage() {
const { data: planData, isLoading } = usePlan()
const [pendingType, setPendingType] = useState<PriceType | null>(null)
const [errorByType, setErrorByType] = useState<Partial<Record<PriceType, string>>>({})
const mutation = useMutation({
mutationFn: createCheckoutSession,
onSuccess: (data) => {
// Redirection full-page vers Stripe Checkout. L'utilisateur reviendra
// sur /dashboard?upgrade=success après paiement (cf. backend success_url).
window.location.href = data.url
},
onError: (err: Error, priceType) => {
setErrorByType((prev) => ({
...prev,
[priceType]:
err.message || 'Impossible de démarrer le paiement. Réessayez dans quelques instants.',
}))
setPendingType(null)
},
})
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 [lastClicked, setLastClicked] = useState<PriceType | null>(null)
function handleUpgrade(priceType: PriceType) {
setErrorByType((prev) => ({ ...prev, [priceType]: undefined }))
setPendingType(priceType)
mutation.mutate(priceType)
setLastClicked(priceType)
checkout(priceType)
}
const plan = (planData?.plan as Plan | undefined) ?? 'free'
const ctaConfigs = buildCtaConfigs(plan, pendingType, handleUpgrade)
const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade)
const errorByType: Partial<Record<PriceType, string>> =
error && lastClicked ? { [lastClicked]: error } : {}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">

View file

@ -0,0 +1,41 @@
/**
* Sprint 5c Banner affiché au retour de Stripe Checkout réussi.
*
* Présentationnel pur (Règle H). Le déclenchement et le nettoyage URL sont
* gérés par `useUpgradeSuccessHandler` côté DashboardPage.
*
* Tokens DA Charcoal exclusivement (Règle L).
*/
import { CheckCircle2, X } from 'lucide-react'
interface Props {
onDismiss: () => void
}
export function UpgradeSuccessBanner({ onDismiss }: Props) {
return (
<div
role="status"
aria-live="polite"
className="mb-6 flex items-start gap-3 rounded-[var(--radius-md)] border border-success/30 bg-success-soft p-4 text-sm"
>
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-success" aria-hidden="true" />
<div className="flex-1">
<p className="font-semibold text-ink-primary">Bienvenue ! Votre plan a é mis à jour.</p>
<p className="mt-0.5 text-xs text-ink-secondary">
Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes la
confirmation Stripe peut prendre un instant.
</p>
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Fermer le message"
className="shrink-0 rounded-md p-1 text-ink-tertiary transition-colors hover:bg-surface-hover hover:text-ink-secondary focus-visible:outline-none focus-visible:shadow-focus"
>
<X className="size-4" aria-hidden="true" />
</button>
</div>
)
}

View file

@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
import { useUpgradeSuccessHandler } from '../useUpgradeSuccessHandler'
let invalidateSpy: ReturnType<typeof vi.fn>
let queryClient: QueryClient
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
function setLocation(search: string) {
window.history.replaceState(null, '', `/dashboard${search}`)
}
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
invalidateSpy = vi.fn().mockResolvedValue(undefined)
// Spy sur la méthode invalidateQueries pour vérifier la clé exacte.
queryClient.invalidateQueries = invalidateSpy as unknown as typeof queryClient.invalidateQueries
})
afterEach(() => {
setLocation('')
})
describe('useUpgradeSuccessHandler', () => {
it('?upgrade=success → showSuccess=true, invalidate(PLAN_QUERY_KEY) appelé, URL nettoyée', () => {
setLocation('?upgrade=success')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(true)
expect(invalidateSpy).toHaveBeenCalledTimes(1)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: PLAN_QUERY_KEY })
// URL nettoyée : plus de `upgrade` dans la query string.
expect(window.location.search).toBe('')
})
it('absence de query param → showSuccess=false, invalidate non appelé', () => {
setLocation('')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(false)
expect(invalidateSpy).not.toHaveBeenCalled()
})
it("?upgrade=cancelled (autre valeur) → showSuccess=false, pas d'action", () => {
setLocation('?upgrade=cancelled')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(false)
expect(invalidateSpy).not.toHaveBeenCalled()
// URL conservée intacte (autre valeur, hors scope du nettoyage).
expect(window.location.search).toBe('?upgrade=cancelled')
})
it('dismiss() bascule showSuccess à false', () => {
setLocation('?upgrade=success')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(true)
act(() => {
result.current.dismiss()
})
expect(result.current.showSuccess).toBe(false)
})
it('conserve les autres query params (utm_*, etc.) lors du nettoyage', () => {
setLocation('?upgrade=success&utm_source=email&ref=abc')
renderHook(() => useUpgradeSuccessHandler(), { wrapper })
const params = new URLSearchParams(window.location.search)
expect(params.has('upgrade')).toBe(false)
expect(params.get('utm_source')).toBe('email')
expect(params.get('ref')).toBe('abc')
})
})

View file

@ -0,0 +1,60 @@
/**
* Sprint 5c Détection retour Stripe Checkout réussi.
*
* Lit `?upgrade=success` au mount de la page Dashboard, déclenche :
* 1. invalidation du cache plan (`PLAN_QUERY_KEY`) refetch automatique
* du plan mis à jour par le webhook backend `checkout.session.completed`,
* 2. affichage d'un banner de succès (consommé par DashboardPage),
* 3. nettoyage du query param via `history.replaceState` (un refresh ne
* doit pas re-déclencher le banner).
*
* Indépendant de react-router (lit `window.location.search` directement)
* pour faciliter les tests sans MemoryRouter.
*
* Race connue (Sprint 5c) : le webhook Stripe peut arriver après le
* redirect frontend (latence ~1-3 s). Si l'invalidation refetch trop tôt,
* `usePlan()` retourne encore l'ancien plan. Mitigation MVP : message
* neutre + refresh manuel résoud. Polling/retry à tracer en FTD si
* problème observé en production.
*/
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
export interface UseUpgradeSuccessHandlerResult {
showSuccess: boolean
dismiss: () => void
}
const QUERY_PARAM = 'upgrade'
const SUCCESS_VALUE = 'success'
export function useUpgradeSuccessHandler(): UseUpgradeSuccessHandlerResult {
const [showSuccess, setShowSuccess] = useState(false)
const queryClient = useQueryClient()
useEffect(() => {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
if (params.get(QUERY_PARAM) !== SUCCESS_VALUE) return
setShowSuccess(true)
void queryClient.invalidateQueries({ queryKey: PLAN_QUERY_KEY })
// Nettoyage URL : retire UNIQUEMENT le param `upgrade`, conserve les autres
// (utm_*, etc.). `replaceState` ne déclenche pas de remount React Router.
params.delete(QUERY_PARAM)
const remaining = params.toString()
const newSearch = remaining.length > 0 ? `?${remaining}` : ''
const newUrl = window.location.pathname + newSearch + window.location.hash
window.history.replaceState(null, '', newUrl)
}, [queryClient])
function dismiss(): void {
setShowSuccess(false)
}
return { showSuccess, dismiss }
}

View file

@ -11,9 +11,11 @@ import { Button } from '@/shared/ui/Button'
import { hasAccess, canSimulate } from '@/entities/user/lib'
import { useAuth } from '@/features/auth/hooks/useAuth'
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
import { useUpgradeSuccessHandler } from '../hooks/useUpgradeSuccessHandler'
import { DashboardFreeView } from '../components/DashboardFreeView'
import { DashboardStandardView } from '../components/DashboardStandardView'
import { DashboardPremiumView } from '../components/DashboardPremiumView'
import { UpgradeSuccessBanner } from '../components/UpgradeSuccessBanner'
function getDisplayName(
user: { user_metadata?: { full_name?: string }; email?: string } | null,
@ -90,8 +92,10 @@ function DashboardContent() {
}
export function DashboardPage() {
const { showSuccess, dismiss } = useUpgradeSuccessHandler()
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
{showSuccess && <UpgradeSuccessBanner onDismiss={dismiss} />}
<DashboardContent />
</div>
)