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>
161 lines
4.5 KiB
TypeScript
161 lines
4.5 KiB
TypeScript
/**
|
||
* 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();
|