From b0e9425ed5c62a3d5e916bfae18c12ea6ce98403 Mon Sep 17 00:00:00 2001 From: ordinarthur Date: Wed, 8 Apr 2026 14:07:02 +0200 Subject: [PATCH] feat(stripe): on-demand sync fallback when webhooks are missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: if stripe listen is not running (dev) or the webhook secret is misconfigured, a successful checkout leaves the user stuck on the free plan in the DB even though Stripe knows they're subscribed. Solution: 3 recovery mechanisms. 1. Backend: POST /stripe/sync (auth required) Fetches the current user's subscriptions from Stripe by customer ID, picks the most recent active/trialing/past_due one, and applies it to the User row via the same applySubscriptionToUser helper used by the webhook. If no active sub exists, downgrades to free. Returns the current plan state. 2. Frontend: CheckoutSuccess now calls /stripe/sync first (instant, reliable) before falling back to polling /stripe/subscription. This fixes the 'just paid but still free' bug even with no webhook setup. 3. Frontend: 'Rafraîchir' button on the Profile free-plan upgrade banner (ghost style with RefreshCw spinning icon). Tooltip hints at its purpose. Users who paid but see the free state can click it to self-heal in one click. 4. Backend script: scripts/sync-subscription.ts - npm run stripe:sync -- user@example.com (sync one user by email) - npm run stripe:sync -- --all (sync every user with a stripeId, useful after a prod webhook outage) Colored output with ✓ / ✗ / ↷ status per user. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/package.json | 1 + backend/scripts/sync-subscription.ts | 160 +++++++++++++++++++++++++ backend/src/routes/stripe.ts | 80 +++++++++++++ frontend/src/api/stripe.ts | 7 ++ frontend/src/pages/CheckoutSuccess.tsx | 61 +++++++--- frontend/src/pages/Profile.tsx | 57 ++++++++- 6 files changed, 344 insertions(+), 22 deletions(-) create mode 100644 backend/scripts/sync-subscription.ts diff --git a/backend/package.json b/backend/package.json index a1f0890..de189bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "stripe:setup": "tsx scripts/setup-stripe.ts", "stripe:setup:write": "tsx scripts/setup-stripe.ts --write-env", "stripe:listen": "stripe listen --forward-to localhost:3000/stripe/webhook", + "stripe:sync": "tsx scripts/sync-subscription.ts", "lint": "eslint src", "format": "prettier --write \"src/**/*.{ts,json}\"" }, diff --git a/backend/scripts/sync-subscription.ts b/backend/scripts/sync-subscription.ts new file mode 100644 index 0000000..581e139 --- /dev/null +++ b/backend/scripts/sync-subscription.ts @@ -0,0 +1,160 @@ +/** + * Script de support : synchronise l'abonnement d'un utilisateur depuis + * Stripe vers la base de données locale. + * + * À utiliser quand un webhook a été raté (par exemple `stripe listen` + * n'était pas démarré pendant le checkout) et qu'un user payant est + * resté bloqué en plan "gratuit" dans la DB. + * + * Usage : + * npm run stripe:sync -- user@example.com + * npm run stripe:sync -- --all # synchronise tous les users avec stripeId + */ + +import 'dotenv/config'; +import Stripe from 'stripe'; +import { PrismaClient } from '@prisma/client'; + +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +function log(msg: string) { + // eslint-disable-next-line no-console + console.log(msg); +} + +interface PlanMapping { + priceId: string | undefined; + plan: 'essential' | 'premium'; +} + +function getPlans(): PlanMapping[] { + return [ + { priceId: process.env.STRIPE_PRICE_ID_ESSENTIAL, plan: 'essential' }, + { priceId: process.env.STRIPE_PRICE_ID_PREMIUM, plan: 'premium' }, + ]; +} + +async function syncUser( + stripe: Stripe, + prisma: PrismaClient, + user: { id: string; email: string; stripeId: string | null } +): Promise<{ plan: string; status: string | null } | null> { + if (!user.stripeId) { + log(` ${YELLOW}↷${RESET} ${user.email} ${DIM}(pas de stripeId)${RESET}`); + return null; + } + + const subscriptions = await stripe.subscriptions.list({ + customer: user.stripeId, + status: 'all', + limit: 5, + }); + + const active = subscriptions.data + .filter((s) => ['active', 'trialing', 'past_due'].includes(s.status)) + .sort((a, b) => b.created - a.created)[0]; + + if (!active) { + await prisma.user.update({ + where: { id: user.id }, + data: { + subscription: 'free', + subscriptionStatus: null, + stripeSubscriptionId: null, + subscriptionPriceId: null, + subscriptionCurrentPeriodEnd: null, + }, + }); + log(` ${DIM}−${RESET} ${user.email} ${DIM}→ free (aucun abo actif sur Stripe)${RESET}`); + return { plan: 'free', status: null }; + } + + const priceId = active.items.data[0]?.price.id; + const mapping = getPlans().find((p) => p.priceId === priceId); + const planId = mapping?.plan ?? 'essential'; + + await prisma.user.update({ + where: { id: user.id }, + data: { + subscription: planId, + subscriptionStatus: active.status, + stripeSubscriptionId: active.id, + subscriptionPriceId: priceId ?? null, + subscriptionCurrentPeriodEnd: new Date(active.current_period_end * 1000), + }, + }); + + log( + ` ${GREEN}✓${RESET} ${user.email} ${DIM}→${RESET} ${BOLD}${planId}${RESET} ${DIM}(${active.status})${RESET}` + ); + return { plan: planId, status: active.status }; +} + +async function main() { + const args = process.argv.slice(2).filter((a) => a !== '--'); + + if (args.length === 0) { + log('Usage : npm run stripe:sync -- user@example.com'); + log(' npm run stripe:sync -- --all'); + process.exit(1); + } + + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + log(`${RED}✗ STRIPE_SECRET_KEY manquante${RESET}`); + process.exit(1); + } + + const stripe = new Stripe(secretKey, { + apiVersion: + (process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16', + }); + const prisma = new PrismaClient(); + + log(''); + log(`${BOLD}🔄 Synchronisation Stripe → DB${RESET}`); + log(''); + + try { + if (args.includes('--all')) { + const users = await prisma.user.findMany({ + where: { stripeId: { not: null } }, + select: { id: true, email: true, stripeId: true }, + }); + log(`${DIM}${users.length} utilisateur(s) avec un stripeId${RESET}`); + log(''); + for (const user of users) { + try { + await syncUser(stripe, prisma, user); + } catch (err) { + log(` ${RED}✗${RESET} ${user.email} : ${(err as Error).message}`); + } + } + } else { + const email = args[0]; + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, email: true, stripeId: true }, + }); + if (!user) { + log(`${RED}✗ Utilisateur introuvable : ${email}${RESET}`); + process.exit(1); + } + await syncUser(stripe, prisma, user); + } + log(''); + log(`${GREEN}✓ Terminé${RESET}`); + } catch (err) { + log(`${RED}✗ ${(err as Error).message}${RESET}`); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/backend/src/routes/stripe.ts b/backend/src/routes/stripe.ts index a0b9be7..fb75823 100644 --- a/backend/src/routes/stripe.ts +++ b/backend/src/routes/stripe.ts @@ -153,6 +153,86 @@ const stripeRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { } ); + // ------------------------------------------------------------------------- + // POST /stripe/sync — Resynchronise l'abonnement depuis Stripe + // ------------------------------------------------------------------------- + // Utile quand le webhook n'est pas arrivé (stripe listen pas démarré, + // whsec mal configuré, etc.) : le frontend peut appeler cet endpoint + // après un checkout pour récupérer l'état réel immédiatement. + fastify.post( + '/sync', + { preHandler: authenticate }, + async (request, reply) => { + if (!fastify.stripe) { + return reply.code(503).send({ error: 'Stripe n\'est pas configuré' }); + } + + try { + const user = await fastify.prisma.user.findUnique({ + where: { id: request.user.id }, + }); + + if (!user) { + return reply.code(404).send({ error: 'Utilisateur non trouvé' }); + } + if (!user.stripeId) { + return reply.code(400).send({ + error: 'Aucun customer Stripe pour cet utilisateur', + }); + } + + // Cherche l'abonnement actif (status active ou trialing) du customer + const subscriptions = await fastify.stripe.subscriptions.list({ + customer: user.stripeId, + status: 'all', + limit: 5, + }); + + // Trie par date de création desc pour prendre le plus récent + const active = subscriptions.data + .filter((s) => ['active', 'trialing', 'past_due'].includes(s.status)) + .sort((a, b) => b.created - a.created)[0]; + + if (!active) { + // Pas d'abonnement actif → repasse en plan gratuit + await fastify.prisma.user.update({ + where: { id: user.id }, + data: { + subscription: 'free', + subscriptionStatus: null, + stripeSubscriptionId: null, + subscriptionPriceId: null, + subscriptionCurrentPeriodEnd: null, + }, + }); + return { plan: 'free', status: 'none', synced: true }; + } + + await applySubscriptionToUser(fastify, active, user.id); + + // Relit l'utilisateur après update + const refreshed = await fastify.prisma.user.findUnique({ + where: { id: user.id }, + select: { + subscription: true, + subscriptionStatus: true, + subscriptionCurrentPeriodEnd: true, + }, + }); + + return { + plan: refreshed?.subscription ?? 'free', + status: refreshed?.subscriptionStatus ?? null, + currentPeriodEnd: refreshed?.subscriptionCurrentPeriodEnd ?? null, + synced: true, + }; + } catch (err) { + fastify.log.error(err, 'stripe sync failed'); + return reply.code(500).send({ error: 'Erreur lors de la synchronisation' }); + } + } + ); + // ------------------------------------------------------------------------- // POST /stripe/portal — Redirige vers le Customer Portal (gestion abo) // ------------------------------------------------------------------------- diff --git a/frontend/src/api/stripe.ts b/frontend/src/api/stripe.ts index b508df5..4bcad3a 100644 --- a/frontend/src/api/stripe.ts +++ b/frontend/src/api/stripe.ts @@ -40,6 +40,13 @@ const stripeService = { if (!url) throw new Error("Pas d'URL de portail reçue"); window.location.href = url; }, + + /** + * Resynchronise l'abonnement depuis Stripe. Utile quand le webhook + * n'est pas arrivé (mode dev sans `stripe listen`, latence, etc.). + */ + syncSubscription: (): Promise => + apiService.post("/stripe/sync", {}), }; export default stripeService; diff --git a/frontend/src/pages/CheckoutSuccess.tsx b/frontend/src/pages/CheckoutSuccess.tsx index 93073eb..4351c95 100644 --- a/frontend/src/pages/CheckoutSuccess.tsx +++ b/frontend/src/pages/CheckoutSuccess.tsx @@ -10,28 +10,57 @@ export default function CheckoutSuccess() { const [subscription, setSubscription] = useState(null) const [polling, setPolling] = useState(true) - // Poll /stripe/subscription jusqu'à ce que le webhook ait activé l'abo - // (max 10 tentatives × 1.5s = 15s, ce qui couvre largement la latence webhook) + // Stratégie en 2 temps : + // 1. Essaye d'abord un sync direct depuis Stripe (instantané, fiable) + // 2. Si ça échoue, poll /stripe/subscription jusqu'à ce que le webhook + // ait activé l'abo (max 10 × 1.5s) useEffect(() => { - let attempts = 0 - const maxAttempts = 10 - const interval = setInterval(async () => { - attempts++ + let cancelled = false + + const hydrate = async () => { + // Tentative #1 : sync immédiat try { - const sub = await stripeService.getSubscription() - setSubscription(sub) - if (sub.hasActiveSubscription || attempts >= maxAttempts) { - clearInterval(interval) + const synced = await stripeService.syncSubscription() + if (cancelled) return + if (synced.hasActiveSubscription || synced.plan !== "free") { + setSubscription(synced) setPolling(false) + return } } catch { - if (attempts >= maxAttempts) { - clearInterval(interval) - setPolling(false) - } + /* on tombe dans le polling */ } - }, 1500) - return () => clearInterval(interval) + + // Tentative #2 : polling (si le sync n'a rien trouvé, ce qui peut + // arriver si Stripe n'a pas encore flushé la subscription) + let attempts = 0 + const maxAttempts = 10 + const interval = setInterval(async () => { + attempts++ + try { + const sub = await stripeService.getSubscription() + if (cancelled) { + clearInterval(interval) + return + } + setSubscription(sub) + if (sub.hasActiveSubscription || attempts >= maxAttempts) { + clearInterval(interval) + setPolling(false) + } + } catch { + if (attempts >= maxAttempts) { + clearInterval(interval) + setPolling(false) + } + } + }, 1500) + } + + hydrate() + return () => { + cancelled = true + } }, []) const planName = diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 6b3aa42..baf69bb 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -33,6 +33,7 @@ import { AlertTriangle, CreditCard, Zap, + RefreshCw, } from "lucide-react"; import { Link } from "react-router-dom"; @@ -90,6 +91,7 @@ export default function Profile() { const [deletePassword, setDeletePassword] = useState(""); const [subscription, setSubscription] = useState(null); const [portalLoading, setPortalLoading] = useState(false); + const [syncLoading, setSyncLoading] = useState(false); const [profileForm, setProfileForm] = useState({ name: "" }); const [passwordForm, setPasswordForm] = useState({ @@ -269,6 +271,24 @@ export default function Profile() { } }; + const handleSyncSubscription = async () => { + setSyncLoading(true); + setError(""); + try { + const synced = await stripeService.syncSubscription(); + setSubscription(synced); + if (synced.plan !== "free") { + setSuccess(`Abonnement ${synced.plan} récupéré avec succès`); + } else { + setSuccess("Aucun abonnement actif trouvé sur Stripe"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Impossible de synchroniser"); + } finally { + setSyncLoading(false); + } + }; + const handleDeleteAccount = async () => { try { await userService.deleteAccount(deletePassword); @@ -464,7 +484,9 @@ export default function Profile() { {/* --- Tabs --- */} @@ -948,10 +970,18 @@ export default function Profile() { interface SubscriptionCardProps { subscription: SubscriptionStatus | null; onManage: () => void; + onSync: () => void; portalLoading: boolean; + syncLoading: boolean; } -function SubscriptionCard({ subscription, onManage, portalLoading }: SubscriptionCardProps) { +function SubscriptionCard({ + subscription, + onManage, + onSync, + portalLoading, + syncLoading, +}: SubscriptionCardProps) { // État de chargement if (!subscription) { return ( @@ -1003,12 +1033,27 @@ function SubscriptionCard({ subscription, onManage, portalLoading }: Subscriptio Recettes illimitées, images HD, support prioritaire. À partir de 3€/mois.

- - - + + + + );