Before: 'Gérer l'abonnement' opened the generic Customer Portal. If the user cancelled, the portal's 'return_url' was just a button label — nothing auto-redirected back to Freedge, so the user was stranded on billing.stripe.com after clicking 'Cancel'. Now: dedicated 'Annuler' button on the Profile SubscriptionCard that calls a new backend endpoint POST /stripe/portal/cancel. This creates a portal session with flow_data.type = 'subscription_cancel' deep-linked to the user's active subscription, plus after_completion.type = 'redirect' so Stripe automatically redirects to /subscription/cancelled when the cancellation is confirmed. New page /subscription/cancelled: - Animated heart badge (spring + pulsing halo) - 'À bientôt, on l'espère' title - Info box showing the period-end date (fetched via sync on mount) so the user knows they still have access until the end of the already-paid period - Re-engagement message + 'Retour aux recettes' / 'Voir les plans' CTAs - On mount: calls /stripe/sync so the DB is updated immediately (doesn't wait for the customer.subscription.updated webhook) Profile SubscriptionCard paid-state footer now has two buttons side by side: 'Gérer' (outline) and 'Annuler' (ghost with red hover). Backend verified: Stripe SDK v12 supports flow_data.after_completion. 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