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:
parent
9edfbb3c95
commit
bda7feb196
7 changed files with 371 additions and 25 deletions
107
src/features/billing/__tests__/useStripeCheckout.test.tsx
Normal file
107
src/features/billing/__tests__/useStripeCheckout.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
59
src/features/billing/hooks/useStripeCheckout.ts
Normal file
59
src/features/billing/hooks/useStripeCheckout.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
41
src/features/dashboard/components/UpgradeSuccessBanner.tsx
Normal file
41
src/features/dashboard/components/UpgradeSuccessBanner.tsx
Normal 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 été 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
60
src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
Normal file
60
src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue