From 64d2bf450621f26c403393aa0b43c40ebf7b2115 Mon Sep 17 00:00:00 2001 From: ordinarthur Date: Wed, 8 Apr 2026 13:58:26 +0200 Subject: [PATCH] feat(stripe): idempotent setup script that provisions products + prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New script backend/scripts/setup-stripe.ts that: - Reads STRIPE_SECRET_KEY from .env - Detects test vs live mode and warns + 5s delay for live - For each plan (Essentiel 3EUR/mo, Premium 5EUR/mo): - Looks up existing price by lookup_key (freedge_essential_monthly, freedge_premium_monthly) — idempotent, safe to re-run - If missing, creates the product then the recurring price with the lookup_key and nickname for clarity - Prints the resulting price IDs with their env var names - With --write-env flag, automatically upserts the values into backend/.env preserving other lines - Points to Customer Portal settings and stripe listen command as next steps npm scripts added: - npm run stripe:setup # dry run, just print IDs - npm run stripe:setup:write # update .env automatically - npm run stripe:listen # shortcut for stripe CLI webhook forward Updated README to show the script as the recommended path for step 1, keeping the manual dashboard instructions as a fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 17 ++- backend/package.json | 3 + backend/scripts/setup-stripe.ts | 256 ++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 backend/scripts/setup-stripe.ts diff --git a/README.md b/README.md index ea82604..d50bc2b 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,24 @@ audio (multipart) ### 1. Créer les produits et prix -Dans le [dashboard Stripe](https://dashboard.stripe.com/test/products) : +**Option A — script automatique (recommandé)** + +```bash +cd backend +# Mets juste STRIPE_SECRET_KEY dans .env, puis : +npm run stripe:setup # affiche les IDs à copier +# ou : +npm run stripe:setup:write # écrit directement dans backend/.env +``` + +Le script est idempotent (utilise des `lookup_key` sur les prix) : tu peux le relancer autant de fois que tu veux, il ne créera pas de doublons. + +**Option B — manuel, via le [dashboard Stripe](https://dashboard.stripe.com/test/products)** + 1. Crée deux produits récurrents : - **Essentiel** — 3€/mois - **Premium** — 5€/mois -2. Note les **Price IDs** (commencent par `price_...`). +2. Note les **Price IDs** (commencent par `price_...`) et colle-les dans `backend/.env`. ### 2. Configurer le Customer Portal diff --git a/backend/package.json b/backend/package.json index c46af89..a1f0890 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,9 @@ "typecheck": "tsc --noEmit", "migrate": "prisma migrate dev", "studio": "prisma studio", + "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", "lint": "eslint src", "format": "prettier --write \"src/**/*.{ts,json}\"" }, diff --git a/backend/scripts/setup-stripe.ts b/backend/scripts/setup-stripe.ts new file mode 100644 index 0000000..cabcbf0 --- /dev/null +++ b/backend/scripts/setup-stripe.ts @@ -0,0 +1,256 @@ +/** + * Script d'initialisation Stripe. + * + * Crée (ou récupère s'ils existent déjà) les produits et prix de Freedge, + * puis affiche les IDs à copier dans `.env`. Idempotent — peut être relancé + * sans danger grâce aux `lookup_key` sur les prix. + * + * Usage : + * pnpm stripe:setup # juste afficher les IDs + * pnpm stripe:setup --write-env # écrire directement dans backend/.env + * + * Nécessite `STRIPE_SECRET_KEY` dans l'environnement (chargé depuis .env). + */ + +import 'dotenv/config'; +import Stripe from 'stripe'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// --------------------------------------------------------------------------- +// Configuration des plans à créer +// --------------------------------------------------------------------------- + +interface PlanDefinition { + envKey: 'STRIPE_PRICE_ID_ESSENTIAL' | 'STRIPE_PRICE_ID_PREMIUM'; + lookupKey: string; + product: { + name: string; + description: string; + }; + price: { + unitAmount: number; // centimes + currency: string; + interval: 'month' | 'year'; + }; +} + +const PLANS: PlanDefinition[] = [ + { + envKey: 'STRIPE_PRICE_ID_ESSENTIAL', + lookupKey: 'freedge_essential_monthly', + product: { + name: 'Freedge Essentiel', + description: + '15 recettes par mois, reconnaissance vocale, préférences culinaires, sauvegarde illimitée.', + }, + price: { + unitAmount: 300, // 3,00 € + currency: 'eur', + interval: 'month', + }, + }, + { + envKey: 'STRIPE_PRICE_ID_PREMIUM', + lookupKey: 'freedge_premium_monthly', + product: { + name: 'Freedge Premium', + description: + 'Recettes illimitées, images haute qualité, reconnaissance vocale, support prioritaire.', + }, + price: { + unitAmount: 500, // 5,00 € + currency: 'eur', + interval: 'month', + }, + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const YELLOW = '\x1b[33m'; +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const CYAN = '\x1b[36m'; +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); +} + +function fail(msg: string): never { + log(`${RED}✗ ${msg}${RESET}`); + process.exit(1); +} + +/** + * Trouve un prix existant par `lookup_key`, ou crée le produit + prix + * sinon. Retourne toujours un objet Stripe.Price. + */ +async function ensurePlan( + stripe: Stripe, + plan: PlanDefinition +): Promise { + // Cherche par lookup_key (idempotent) + const existing = await stripe.prices.list({ + lookup_keys: [plan.lookupKey], + expand: ['data.product'], + limit: 1, + }); + + if (existing.data.length > 0) { + const price = existing.data[0]; + const product = price.product as Stripe.Product; + log( + ` ${DIM}→${RESET} ${plan.product.name} ${DIM}(déjà existant)${RESET}` + ); + log(` ${DIM}product: ${product.id}${RESET}`); + log(` ${DIM}price: ${price.id}${RESET}`); + return price; + } + + // Sinon, crée le produit + log(` ${CYAN}+${RESET} Création de ${plan.product.name}…`); + const product = await stripe.products.create({ + name: plan.product.name, + description: plan.product.description, + metadata: { + app: 'freedge', + plan_key: plan.lookupKey, + }, + }); + + // Puis le prix récurrent avec lookup_key pour la prochaine fois + const price = await stripe.prices.create({ + product: product.id, + unit_amount: plan.price.unitAmount, + currency: plan.price.currency, + recurring: { interval: plan.price.interval }, + lookup_key: plan.lookupKey, + nickname: `${plan.product.name} — mensuel`, + metadata: { + app: 'freedge', + plan_key: plan.lookupKey, + }, + }); + + log(` ${GREEN}✓${RESET} product: ${product.id}`); + log(` ${GREEN}✓${RESET} price: ${price.id}`); + return price; +} + +/** + * Met à jour (ou ajoute) une variable dans le fichier .env. + * Préserve l'ordre et les commentaires des autres lignes. + */ +function upsertEnvVar(envPath: string, key: string, value: string): void { + let content = ''; + if (fs.existsSync(envPath)) { + content = fs.readFileSync(envPath, 'utf-8'); + } + + const lineRegex = new RegExp(`^${key}=.*$`, 'm'); + if (lineRegex.test(content)) { + content = content.replace(lineRegex, `${key}=${value}`); + } else { + content = (content.trimEnd() + `\n${key}=${value}\n`).trimStart(); + } + + fs.writeFileSync(envPath, content); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const writeEnv = process.argv.includes('--write-env'); + + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + fail( + 'STRIPE_SECRET_KEY manquante. Définis-la dans backend/.env puis relance.' + ); + } + if (!secretKey.startsWith('sk_')) { + fail(`STRIPE_SECRET_KEY ne ressemble pas à une clé Stripe : ${secretKey.slice(0, 8)}…`); + } + + const isLive = secretKey.startsWith('sk_live_'); + log(''); + log(`${BOLD}🔧 Freedge — Initialisation Stripe${RESET}`); + log( + `${DIM}Mode : ${isLive ? `${RED}LIVE ⚠️${RESET}${DIM}` : `${GREEN}TEST${RESET}${DIM}`}${RESET}` + ); + log(''); + + if (isLive) { + log( + `${YELLOW}⚠️ Tu utilises une clé LIVE. Les produits seront créés en production.${RESET}` + ); + log(`${YELLOW} Appuie sur Ctrl+C dans les 5 secondes pour annuler.${RESET}`); + log(''); + await new Promise((r) => setTimeout(r, 5000)); + } + + const stripe = new Stripe(secretKey, { + apiVersion: + (process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16', + typescript: true, + }); + + log(`${BOLD}Plans à provisionner :${RESET}`); + const results: Array<{ envKey: string; priceId: string }> = []; + for (const plan of PLANS) { + try { + const price = await ensurePlan(stripe, plan); + results.push({ envKey: plan.envKey, priceId: price.id }); + } catch (err) { + log(` ${RED}✗${RESET} ${plan.product.name} : ${(err as Error).message}`); + process.exit(1); + } + } + + log(''); + log(`${BOLD}Résultat :${RESET}`); + log(''); + for (const { envKey, priceId } of results) { + log(` ${CYAN}${envKey}${RESET}=${priceId}`); + } + log(''); + + if (writeEnv) { + const envPath = path.resolve(process.cwd(), '.env'); + for (const { envKey, priceId } of results) { + upsertEnvVar(envPath, envKey, priceId); + } + log(`${GREEN}✓${RESET} ${envPath} mis à jour.`); + } else { + log( + `${DIM}Ajoute ces lignes à backend/.env (ou relance avec --write-env pour le faire automatiquement).${RESET}` + ); + } + + log(''); + + // Rappels utiles + log(`${BOLD}Prochaines étapes :${RESET}`); + log(` 1. ${DIM}Activer le Customer Portal${RESET}`); + log( + ` ${DIM}https://dashboard.stripe.com/${isLive ? '' : 'test/'}settings/billing/portal${RESET}` + ); + log(` 2. ${DIM}Lancer le listener webhook en local${RESET}`); + log(` ${DIM}stripe listen --forward-to localhost:3000/stripe/webhook${RESET}`); + log(` 3. ${DIM}Copier le whsec_ affiché dans STRIPE_WEBHOOK_SECRET${RESET}`); + log(''); +} + +main().catch((err) => { + log(''); + fail(err instanceof Error ? err.message : String(err)); +});