freedge/backend/scripts/sync-subscription.ts
ordinarthur b0e9425ed5 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>
2026-04-08 14:07:02 +02:00

161 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();