feat(stripe): on-demand sync fallback when webhooks are missed
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) <noreply@anthropic.com>
This commit is contained in:
parent
8d1202ca34
commit
b0e9425ed5
@ -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}\""
|
||||
},
|
||||
|
||||
160
backend/scripts/sync-subscription.ts
Normal file
160
backend/scripts/sync-subscription.ts
Normal file
@ -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();
|
||||
@ -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)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ -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<SubscriptionStatus & { synced: boolean }> =>
|
||||
apiService.post("/stripe/sync", {}),
|
||||
};
|
||||
|
||||
export default stripeService;
|
||||
|
||||
@ -10,15 +10,39 @@ export default function CheckoutSuccess() {
|
||||
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(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 cancelled = false
|
||||
|
||||
const hydrate = async () => {
|
||||
// Tentative #1 : sync immédiat
|
||||
try {
|
||||
const synced = await stripeService.syncSubscription()
|
||||
if (cancelled) return
|
||||
if (synced.hasActiveSubscription || synced.plan !== "free") {
|
||||
setSubscription(synced)
|
||||
setPolling(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* on tombe dans le polling */
|
||||
}
|
||||
|
||||
// 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)
|
||||
@ -31,7 +55,12 @@ export default function CheckoutSuccess() {
|
||||
}
|
||||
}
|
||||
}, 1500)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
|
||||
hydrate()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const planName =
|
||||
|
||||
@ -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<SubscriptionStatus | null>(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() {
|
||||
<SubscriptionCard
|
||||
subscription={subscription}
|
||||
onManage={handleOpenPortal}
|
||||
onSync={handleSyncSubscription}
|
||||
portalLoading={portalLoading}
|
||||
syncLoading={syncLoading}
|
||||
/>
|
||||
|
||||
{/* --- 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,13 +1033,28 @@ function SubscriptionCard({ subscription, onManage, portalLoading }: Subscriptio
|
||||
Recettes illimitées, images HD, support prioritaire. À partir de 3€/mois.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/pricing" className="w-full md:w-auto">
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSync}
|
||||
disabled={syncLoading}
|
||||
className="rounded-full text-muted-foreground hover:bg-white/60 dark:hover:bg-slate-800/60"
|
||||
title="Tu viens de payer et ton plan n'est pas encore à jour ? Clique ici."
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${syncLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{syncLoading ? "Synchronisation…" : "Rafraîchir"}
|
||||
</Button>
|
||||
<Link to="/pricing" className="w-full sm:w-auto">
|
||||
<Button className="w-full h-11 px-6 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md hover:shadow-lg transition-all font-semibold">
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Voir les plans
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user