feat(stripe): idempotent setup script that provisions products + prices
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) <noreply@anthropic.com>
This commit is contained in:
parent
339de4c44c
commit
64d2bf4506
17
README.md
17
README.md
@ -184,11 +184,24 @@ audio (multipart)
|
|||||||
|
|
||||||
### 1. Créer les produits et prix
|
### 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 :
|
1. Crée deux produits récurrents :
|
||||||
- **Essentiel** — 3€/mois
|
- **Essentiel** — 3€/mois
|
||||||
- **Premium** — 5€/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
|
### 2. Configurer le Customer Portal
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,9 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"migrate": "prisma migrate dev",
|
"migrate": "prisma migrate dev",
|
||||||
"studio": "prisma studio",
|
"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",
|
"lint": "eslint src",
|
||||||
"format": "prettier --write \"src/**/*.{ts,json}\""
|
"format": "prettier --write \"src/**/*.{ts,json}\""
|
||||||
},
|
},
|
||||||
|
|||||||
256
backend/scripts/setup-stripe.ts
Normal file
256
backend/scripts/setup-stripe.ts
Normal file
@ -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<Stripe.Price> {
|
||||||
|
// 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));
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user