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>
Freedge
Freedge génère des recettes personnalisées à partir des ingrédients dictés à l'oral. Le son est transcrit (Whisper), une recette est générée (GPT-4o-mini) et une image optionnelle est produite (DALL-E 3).
Stack
- Frontend : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router
- Backend : Fastify 4 + TypeScript + Prisma 5 + SQLite
- IA : OpenAI (gpt-4o-mini-transcribe, gpt-4o-mini avec Structured Outputs, gpt-image-1)
- Stockage : MinIO (S3-compatible) avec fallback local
- Paiement : Stripe Checkout hébergé + Customer Portal + webhooks signés
- Auth : JWT + Google OAuth
Structure
freedge/
├── backend/ # TypeScript
│ ├── prisma/ # Schéma + migrations SQLite
│ ├── tsconfig.json
│ └── src/
│ ├── ai/ # prompts, recipe-generator, image-generator,
│ │ # transcriber, cost (logging tokens/USD)
│ ├── plugins/ # auth, ai, stripe, google-auth
│ ├── routes/ # auth, recipes, users
│ ├── types/ # fastify.d.ts (augmentation décorateurs)
│ ├── utils/ # env, storage (MinIO), email
│ └── server.ts
└── frontend/
└── src/
├── api/ # Clients HTTP (auth, user, recipe)
├── components/ # UI shadcn + composants métier
├── hooks/ # useAuth, useMobile, useAudioRecorder
├── layouts/ # MainLayout
└── pages/ # Home, Auth/*, Recipes/*, Profile, ResetPassword
Prérequis
- Node.js ≥ 18
- pnpm (recommandé) ou npm
- Une clé API OpenAI
- (Optionnel) Un serveur MinIO pour le stockage images/audio
- (Optionnel) Un compte Resend pour les emails
Démarrage
# 1. Services (MinIO) — depuis la racine du projet
docker compose up -d
# → MinIO sur :9000 (API) + :9001 (console web, user freedge / pass freedge123)
# → le bucket 'freedge' est créé automatiquement et rendu lisible publiquement
# 2. Installation
cd backend && npm install
cd ../frontend && pnpm install
# 3. Variables d'environnement backend (.env dans backend/)
cp .env.example .env # puis éditer OPENAI_API_KEY et JWT_SECRET
# Les défauts MinIO du .env.example pointent sur la stack docker-compose
# 4. Base de données
cd backend
npx prisma migrate dev
npx prisma generate
# 5. Lancement
npm run dev # backend (tsx watch) sur :3000
cd ../frontend && pnpm dev # frontend sur :5173
# Build production backend
cd backend && npm run build && npm start # compile vers dist/ puis lance node dist/server.js
npm run typecheck # vérification TS sans build
Variables d'environnement backend
| Variable | Requis | Description |
|---|---|---|
DATABASE_URL |
✅ | URL Prisma (ex: file:./prisma/dev.db) |
JWT_SECRET |
✅ | Clé JWT (≥ 32 caractères recommandé) |
OPENAI_API_KEY |
✅ | Clé API OpenAI |
PORT |
❌ | Port du serveur (défaut 3000) |
CORS_ORIGINS |
❌ | Origines autorisées, séparées par virgule |
FRONTEND_URL |
❌ | URL frontend pour les emails de reset |
OPENAI_TEXT_MODEL |
❌ | Défaut gpt-4o-mini (recette) |
OPENAI_TRANSCRIBE_MODEL |
❌ | Défaut gpt-4o-mini-transcribe |
OPENAI_IMAGE_MODEL |
❌ | Défaut gpt-image-1 (requiert org vérifiée sur OpenAI) |
OPENAI_IMAGE_FALLBACK_MODEL |
❌ | Défaut dall-e-3, utilisé si le principal échoue |
OPENAI_IMAGE_QUALITY |
❌ | low / medium / high (défaut medium) |
OPENAI_IMAGE_SIZE |
❌ | Défaut 1024x1024 |
ENABLE_IMAGE_GENERATION |
❌ | false pour désactiver totalement la génération d'image |
OPENAI_MAX_RETRIES |
❌ | Retries SDK OpenAI (défaut 3) |
OPENAI_TIMEOUT_MS |
❌ | Timeout par requête (défaut 60000) |
STRIPE_SECRET_KEY |
❌ | Clé Stripe (pour créer les customers) |
MINIO_ENDPOINT / MINIO_PORT / MINIO_USE_SSL / MINIO_ACCESS_KEY / MINIO_SECRET_KEY / MINIO_BUCKET |
❌ | Config MinIO ; fallback local ./uploads sinon |
PUBLIC_BASE_URL |
❌ | URL publique du backend pour les fichiers locaux (défaut http://localhost:3000) |
MINIO_ALLOW_SELF_SIGNED |
❌ | true pour autoriser TLS auto-signé (DEV uniquement) |
RESEND_API_KEY |
❌ | Clé Resend pour les emails |
Routes API (préfixe /)
Auth (/auth)
POST /auth/register— Inscription email + mot de passePOST /auth/login— ConnexionPOST /auth/google-auth— Connexion/inscription via Google OAuth
Utilisateurs (/users)
GET /users/profile— Profil courant (🔒)PUT /users/profile— Mise à jour du nom (🔒)PUT /users/change-password— Changement de mot de passe (🔒)PUT /users/change-email— Changement d'email (🔒)POST /users/forgot-password— Demande de réinitialisationPOST /users/reset-password— Réinitialisation avec tokenDELETE /users/account— Suppression du compte (🔒)
Recettes (/recipes) — toutes 🔒
POST /recipes/create— Upload audio + transcription + générationPOST /recipes/create-stream— Version streaming SSEGET /recipes/list— Liste les recettes de l'utilisateurGET /recipes/:id— Détail d'une recetteDELETE /recipes/:id— Supprime une recette
Stripe (/stripe)
GET /stripe/plans— Liste publique des plans disponiblesGET /stripe/subscription— Statut d'abonnement de l'utilisateur 🔒POST /stripe/checkout— Crée une Checkout Session (body:{ plan }) 🔒POST /stripe/portal— Ouvre le Customer Portal 🔒POST /stripe/webhook— Receiver d'événements Stripe (signature vérifiée)
Divers
GET /health— Healthcheck
🔒 = nécessite un JWT Authorization: Bearer <token>
Stockage des fichiers (images & audio)
Deux modes, avec fallback automatique :
-
MinIO (recommandé, lancé via
docker compose up -d)- Bucket S3-compatible, accessible en lecture publique
- Console web : http://localhost:9001 (user
freedge, passfreedge123) - API S3 : http://localhost:9000
-
Local (fallback si MinIO est indisponible)
- Les fichiers sont écrits dans
backend/uploads/{audio,recipes}/ - Servis par Fastify sur
GET /uploads/* - L'URL publique retournée au frontend est
${PUBLIC_BASE_URL}/uploads/recipes/xxx.png
- Les fichiers sont écrits dans
Le frontend n'a pas à se soucier du mode : il reçoit une URL publique dans tous les cas.
Pipeline IA
Le module src/ai/ est isolé du reste du backend pour faciliter les itérations.
audio (multipart)
└─→ saveAudioFile (MinIO ou local)
└─→ transcribeAudio (gpt-4o-mini-transcribe, ~3s)
└─→ generateRecipe (gpt-4o-mini, json_schema strict, ~3-5s)
├─→ Recette structurée riche (titre, description,
│ inspiration, ingrédients avec notes, étapes
│ chronométrées, accord boisson, conseils)
└─→ generateRecipeImage (gpt-image-1 medium, ~6s)
(parallélisée avec la sérialisation JSON)
Caractéristiques :
- Structured Outputs strict : la réponse JSON est validée côté OpenAI
via
json_schema. Plus jamais deJSON.parsequi casse. - Prompt système long et stable (~1500 tokens, persona "chef Antoine" avec règles d'originalité, de précision technique, et exemples few-shot). → Bénéficie du prompt caching automatique d'OpenAI (-50% sur la portion cachée à partir du 2ᵉ appel).
- Retries + timeout au niveau du SDK OpenAI (configurables via env).
- Logs de coût : chaque appel OpenAI émet un log structuré avec
model,durationMs,costUsd,usageetcacheHit. Permet de suivre les coûts par utilisateur et de mesurer l'effet du cache. - Image best-effort : un échec de génération d'image ne casse pas la création de recette.
Stripe — configuration
1. Créer les produits et prix
Option A — script automatique (recommandé)
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
- Crée deux produits récurrents :
- Essentiel — 3€/mois
- Premium — 5€/mois
- Note les Price IDs (commencent par
price_...) et colle-les dansbackend/.env.
2. Configurer le Customer Portal
Dans Settings → Billing → Customer Portal :
- Active Cancel subscriptions (pour permettre l'annulation)
- Active Switch plans et ajoute les 2 produits (pour permettre le changement)
- Active Update payment methods
- Dans "Branding", renseigne le nom Freedge et l'URL de support
3. Variables d'environnement backend
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID_ESSENTIAL=price_...
STRIPE_PRICE_ID_PREMIUM=price_...
4. Tester les webhooks en local
Installe la CLI Stripe : brew install stripe/stripe-cli/stripe.
stripe login
stripe listen --forward-to localhost:3000/stripe/webhook
# → affiche "whsec_..." à coller dans STRIPE_WEBHOOK_SECRET
Dans un autre terminal, tu peux simuler un événement :
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
5. Cartes de test
Utilise les numéros fournis par Stripe dans le dashboard test :
4242 4242 4242 4242→ paiement réussi4000 0000 0000 9995→ carte déclinée
Sécurité
- Helmet + rate-limit (100 req/min) activés
- CORS whitelisté via
CORS_ORIGINS - JWT signé, expiration 7 jours
- Bcrypt (10 rounds) pour les mots de passe
- Validation des variables d'environnement au démarrage
Licence
MIT