diff --git a/README.md b/README.md index 95ef37c..ea82604 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Freedge génère des recettes personnalisées à partir des ingrédients dictés - **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 (client créé à l'inscription — intégration abonnement à finaliser) +- **Paiement** : Stripe Checkout hébergé + Customer Portal + webhooks signés - **Auth** : JWT + Google OAuth ## Structure @@ -116,10 +116,18 @@ npm run typecheck # vérification TS sans build ### Recettes (`/recipes`) — toutes 🔒 - `POST /recipes/create` — Upload audio + transcription + génération +- `POST /recipes/create-stream` — Version streaming SSE - `GET /recipes/list` — Liste les recettes de l'utilisateur - `GET /recipes/:id` — Détail d'une recette - `DELETE /recipes/:id` — Supprime une recette +### Stripe (`/stripe`) +- `GET /stripe/plans` — Liste publique des plans disponibles +- `GET /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 @@ -172,6 +180,56 @@ audio (multipart) - **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 + +Dans 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. Configurer le Customer Portal + +Dans [Settings → Billing → Customer Portal](https://dashboard.stripe.com/test/settings/billing/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 + +```bash +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`. + +```bash +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 : +```bash +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](https://docs.stripe.com/testing) : +- `4242 4242 4242 4242` → paiement réussi +- `4000 0000 0000 9995` → carte déclinée + ## Sécurité - Helmet + rate-limit (100 req/min) activés diff --git a/backend/.env.example b/backend/.env.example index e7c7a6b..319f65a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,8 +39,16 @@ OPENAI_IMAGE_SIZE=1024x1024 OPENAI_MAX_RETRIES=3 OPENAI_TIMEOUT_MS=60000 -# ---- Stripe (optionnel) ---- +# ---- Stripe ---- +# Clé secrète (commence par sk_test_ en dev, sk_live_ en prod) STRIPE_SECRET_KEY= +# Secret webhook (affiché par `stripe listen --forward-to localhost:3000/stripe/webhook`) +STRIPE_WEBHOOK_SECRET= +# IDs des prix récurrents créés dans le dashboard Stripe +STRIPE_PRICE_ID_ESSENTIAL=price_... +STRIPE_PRICE_ID_PREMIUM=price_... +# Version d'API (facultatif, défaut = 2023-10-16) +# STRIPE_API_VERSION=2023-10-16 # ---- MinIO (démarré avec `docker-compose up -d` depuis la racine du projet) ---- # Laisse vide pour désactiver et utiliser uniquement le stockage local ./uploads diff --git a/backend/prisma/migrations/20260408_add_stripe_subscription_fields/migration.sql b/backend/prisma/migrations/20260408_add_stripe_subscription_fields/migration.sql new file mode 100644 index 0000000..50b77e4 --- /dev/null +++ b/backend/prisma/migrations/20260408_add_stripe_subscription_fields/migration.sql @@ -0,0 +1,8 @@ +-- Add Stripe subscription tracking fields to User + +ALTER TABLE "User" ADD COLUMN "stripeSubscriptionId" TEXT; +ALTER TABLE "User" ADD COLUMN "subscriptionStatus" TEXT; +ALTER TABLE "User" ADD COLUMN "subscriptionPriceId" TEXT; +ALTER TABLE "User" ADD COLUMN "subscriptionCurrentPeriodEnd" DATETIME; + +CREATE UNIQUE INDEX "User_stripeSubscriptionId_key" ON "User"("stripeSubscriptionId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d1d25a7..e9b27e2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -13,11 +13,17 @@ model User { password String? // Optionnel pour les utilisateurs Google name String googleId String? @unique - stripeId String? @unique // Optionnel : Stripe peut être désactivé - subscription String? resetToken String? resetTokenExpiry DateTime? + // ---- Stripe / Abonnement ---- + stripeId String? @unique // Customer ID Stripe + subscription String? // 'free' | 'essential' | 'premium' + stripeSubscriptionId String? @unique + subscriptionStatus String? // active | trialing | past_due | canceled | incomplete + subscriptionPriceId String? + subscriptionCurrentPeriodEnd DateTime? + // Préférences culinaires injectées dans le prompt IA dietaryPreference String? // 'vegetarian' | 'vegan' | 'pescatarian' | 'none' allergies String? // Liste séparée par des virgules : "arachides,gluten" diff --git a/backend/src/plugins/stripe.ts b/backend/src/plugins/stripe.ts index ac0ecc6..f0594c8 100644 --- a/backend/src/plugins/stripe.ts +++ b/backend/src/plugins/stripe.ts @@ -2,6 +2,62 @@ import fp from 'fastify-plugin'; import Stripe from 'stripe'; import type { FastifyInstance } from 'fastify'; +/** + * Catalogue des plans supportés par l'application. Les `priceId` sont + * injectés via les variables d'environnement pour faciliter le passage + * test → prod sans redéploiement. + */ +export interface StripePlan { + id: 'essential' | 'premium'; + name: string; + description: string; + priceId: string | undefined; + monthlyRecipes: number | null; + features: string[]; +} + +export function getPlans(): StripePlan[] { + return [ + { + id: 'essential', + name: 'Essentiel', + description: 'Pour les cuisiniers réguliers', + priceId: process.env.STRIPE_PRICE_ID_ESSENTIAL, + monthlyRecipes: 15, + features: [ + '15 recettes par mois', + 'Reconnaissance vocale des ingrédients', + 'Préférences culinaires', + 'Sauvegarde des recettes', + 'Support par email', + ], + }, + { + id: 'premium', + name: 'Premium', + description: 'Pour les passionnés de cuisine', + priceId: process.env.STRIPE_PRICE_ID_PREMIUM, + monthlyRecipes: null, + features: [ + 'Recettes illimitées', + 'Reconnaissance vocale des ingrédients', + 'Préférences culinaires', + 'Sauvegarde des recettes', + 'Images haute qualité', + 'Support prioritaire', + ], + }, + ]; +} + +export function getPlanById(id: string): StripePlan | undefined { + return getPlans().find((p) => p.id === id); +} + +export function getPlanByPriceId(priceId: string): StripePlan | undefined { + return getPlans().find((p) => p.priceId === priceId); +} + export default fp(async function stripePlugin(fastify: FastifyInstance) { const key = process.env.STRIPE_SECRET_KEY; if (!key) { @@ -10,13 +66,15 @@ export default fp(async function stripePlugin(fastify: FastifyInstance) { } const stripe = new Stripe(key, { - apiVersion: (process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16', + apiVersion: + (process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16', + typescript: true, }); fastify.decorate('stripe', stripe); fastify.decorate('createCustomer', (email: string, name: string) => - stripe.customers.create({ email, name }) + stripe.customers.create({ email, name, metadata: { source: 'freedge' } }) ); fastify.decorate('createSubscription', (customerId: string, priceId: string) => diff --git a/backend/src/routes/stripe.ts b/backend/src/routes/stripe.ts new file mode 100644 index 0000000..a0b9be7 --- /dev/null +++ b/backend/src/routes/stripe.ts @@ -0,0 +1,348 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; +import type Stripe from 'stripe'; +import { getPlans, getPlanById, getPlanByPriceId } from '../plugins/stripe'; + +interface CheckoutBody { + plan: 'essential' | 'premium'; +} + +const stripeRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { + const authenticate = async (request: FastifyRequest, reply: FastifyReply) => { + try { + await fastify.authenticate(request, reply); + } catch { + reply.code(401).send({ error: 'Authentification requise' }); + } + }; + + // ------------------------------------------------------------------------- + // GET /stripe/plans — Liste publique des plans (pas besoin d'auth) + // ------------------------------------------------------------------------- + fastify.get('/plans', async () => { + const plans = getPlans().map((plan) => ({ + id: plan.id, + name: plan.name, + description: plan.description, + monthlyRecipes: plan.monthlyRecipes, + features: plan.features, + // priceId est omis : info sensible, pas utile au frontend + available: !!plan.priceId, + })); + return { plans }; + }); + + // ------------------------------------------------------------------------- + // GET /stripe/subscription — Statut d'abonnement de l'utilisateur courant + // ------------------------------------------------------------------------- + fastify.get( + '/subscription', + { preHandler: authenticate }, + async (request, reply) => { + try { + const user = await fastify.prisma.user.findUnique({ + where: { id: request.user.id }, + select: { + subscription: true, + subscriptionStatus: true, + subscriptionPriceId: true, + subscriptionCurrentPeriodEnd: true, + stripeSubscriptionId: true, + }, + }); + + if (!user) { + return reply.code(404).send({ error: 'Utilisateur non trouvé' }); + } + + const plan = user.subscription || 'free'; + return { + plan, + status: user.subscriptionStatus ?? (plan === 'free' ? 'none' : null), + currentPeriodEnd: user.subscriptionCurrentPeriodEnd, + hasActiveSubscription: !!user.stripeSubscriptionId, + }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ error: 'Erreur lors de la récupération' }); + } + } + ); + + // ------------------------------------------------------------------------- + // POST /stripe/checkout — Crée une Checkout Session et renvoie l'URL + // ------------------------------------------------------------------------- + fastify.post<{ Body: CheckoutBody }>( + '/checkout', + { + preHandler: authenticate, + schema: { + body: { + type: 'object', + required: ['plan'], + properties: { + plan: { type: 'string', enum: ['essential', 'premium'] }, + }, + }, + }, + }, + async (request, reply) => { + if (!fastify.stripe) { + return reply.code(503).send({ error: 'Stripe n\'est pas configuré' }); + } + + try { + const planConfig = getPlanById(request.body.plan); + if (!planConfig || !planConfig.priceId) { + return reply.code(400).send({ + error: `Plan "${request.body.plan}" non disponible. Vérifiez que STRIPE_PRICE_ID_${request.body.plan.toUpperCase()} est défini.`, + }); + } + + const user = await fastify.prisma.user.findUnique({ + where: { id: request.user.id }, + }); + if (!user) { + return reply.code(404).send({ error: 'Utilisateur non trouvé' }); + } + + // Crée le customer Stripe si absent (ancien utilisateurs, Stripe auparavant désactivé) + let customerId = user.stripeId; + if (!customerId) { + if (!fastify.createCustomer) { + return reply.code(503).send({ error: 'Stripe non initialisé' }); + } + const customer = await fastify.createCustomer(user.email, user.name); + customerId = customer.id; + await fastify.prisma.user.update({ + where: { id: user.id }, + data: { stripeId: customerId }, + }); + } + + const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + + const session = await fastify.stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + client_reference_id: user.id, + line_items: [{ price: planConfig.priceId, quantity: 1 }], + success_url: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${appUrl}/pricing`, + allow_promotion_codes: true, + billing_address_collection: 'auto', + // Pas de payment_method_types : on laisse Stripe choisir + // dynamiquement selon la région de l'utilisateur (meilleure + // conversion). Active les moyens de paiement depuis le dashboard. + subscription_data: { + metadata: { + userId: user.id, + plan: planConfig.id, + }, + }, + metadata: { + userId: user.id, + plan: planConfig.id, + }, + }); + + return { url: session.url, sessionId: session.id }; + } catch (err) { + fastify.log.error(err, 'checkout session creation failed'); + return reply.code(500).send({ error: 'Erreur lors de la création de la session' }); + } + } + ); + + // ------------------------------------------------------------------------- + // POST /stripe/portal — Redirige vers le Customer Portal (gestion abo) + // ------------------------------------------------------------------------- + fastify.post( + '/portal', + { preHandler: authenticate }, + async (request, reply) => { + if (!fastify.stripe) { + return reply.code(503).send({ error: 'Stripe n\'est pas configuré' }); + } + + try { + const user = await fastify.prisma.user.findUnique({ + where: { id: request.user.id }, + }); + + if (!user || !user.stripeId) { + return reply.code(400).send({ + error: 'Aucun compte de facturation. Commence par souscrire un abonnement.', + }); + } + + const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + + const portalSession = await fastify.stripe.billingPortal.sessions.create({ + customer: user.stripeId, + return_url: `${appUrl}/profile`, + }); + + return { url: portalSession.url }; + } catch (err) { + fastify.log.error(err, 'portal session creation failed'); + return reply.code(500).send({ error: 'Erreur lors de la création du portail' }); + } + } + ); + + // ------------------------------------------------------------------------- + // POST /stripe/webhook — Receiver d'événements Stripe (signature vérifiée) + // ------------------------------------------------------------------------- + fastify.post('/webhook', async (request, reply) => { + if (!fastify.stripe) { + return reply.code(503).send({ error: 'Stripe non configuré' }); + } + + const signature = request.headers['stripe-signature']; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + if (!signature || typeof signature !== 'string') { + return reply.code(400).send({ error: 'Signature manquante' }); + } + if (!webhookSecret) { + fastify.log.error('STRIPE_WEBHOOK_SECRET non défini'); + return reply.code(500).send({ error: 'Webhook non configuré' }); + } + if (!request.rawBody) { + fastify.log.error('Raw body non disponible — vérifier le content-type parser'); + return reply.code(400).send({ error: 'Raw body indisponible' }); + } + + let event: Stripe.Event; + try { + event = fastify.stripe.webhooks.constructEvent( + request.rawBody, + signature, + webhookSecret + ); + } catch (err) { + fastify.log.warn(`Webhook signature invalide: ${(err as Error).message}`); + return reply.code(400).send({ error: 'Signature invalide' }); + } + + fastify.log.info({ type: event.type, id: event.id }, 'stripe_webhook_received'); + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + // La session de checkout est terminée. On attrape tout de suite + // la subscription associée pour avoir le priceId + dates. + if (session.mode === 'subscription' && session.subscription) { + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id; + const subscription = await fastify.stripe.subscriptions.retrieve(subscriptionId); + await applySubscriptionToUser(fastify, subscription, session.client_reference_id); + } + break; + } + + case 'customer.subscription.created': + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription; + await applySubscriptionToUser(fastify, subscription, null); + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + await fastify.prisma.user.updateMany({ + where: { stripeSubscriptionId: subscription.id }, + data: { + subscription: 'free', + subscriptionStatus: 'canceled', + stripeSubscriptionId: null, + subscriptionPriceId: null, + subscriptionCurrentPeriodEnd: null, + }, + }); + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = + typeof invoice.subscription === 'string' ? invoice.subscription : null; + if (subscriptionId) { + await fastify.prisma.user.updateMany({ + where: { stripeSubscriptionId: subscriptionId }, + data: { subscriptionStatus: 'past_due' }, + }); + } + break; + } + + default: + // On ignore silencieusement les événements qui ne nous intéressent pas + break; + } + } catch (err) { + fastify.log.error(err, `webhook handler failed for ${event.type}`); + // On renvoie 500 pour que Stripe retente — sauf si c'est une erreur + // de validation métier qui ne sera pas résolue par un retry + return reply.code(500).send({ error: 'Handler failed' }); + } + + return { received: true }; + }); +}; + +/** + * Applique l'état d'une Subscription Stripe sur l'utilisateur correspondant. + * Résout l'utilisateur via (dans l'ordre) : + * 1. `clientReferenceId` (posé par checkout.session.completed) + * 2. `subscription.metadata.userId` + * 3. `customer` (stripeId) + */ +async function applySubscriptionToUser( + fastify: FastifyInstance, + subscription: Stripe.Subscription, + clientReferenceId: string | null +): Promise { + const customerId = + typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer.id; + + const priceId = subscription.items.data[0]?.price.id; + const plan = priceId ? getPlanByPriceId(priceId) : undefined; + + let userId: string | null = clientReferenceId; + if (!userId && subscription.metadata?.userId) { + userId = subscription.metadata.userId; + } + + const data = { + stripeSubscriptionId: subscription.id, + subscriptionStatus: subscription.status, + subscriptionPriceId: priceId ?? null, + subscriptionCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), + subscription: plan?.id ?? 'essential', // fallback safe + stripeId: customerId, + }; + + if (userId) { + await fastify.prisma.user.update({ where: { id: userId }, data }).catch(async () => { + // L'utilisateur a pu être supprimé entre-temps, ou l'ID est invalide. + // On retombe sur une résolution par customerId. + await fastify.prisma.user.updateMany({ + where: { stripeId: customerId }, + data, + }); + }); + } else { + await fastify.prisma.user.updateMany({ + where: { stripeId: customerId }, + data, + }); + } +} + +export default stripeRoutes; diff --git a/backend/src/server.ts b/backend/src/server.ts index 68ecf26..af9d76a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,6 +18,7 @@ import googleAuthPlugin from './plugins/google-auth'; import authRoutes from './routes/auth'; import recipesRoutes from './routes/recipes'; import usersRoutes from './routes/users'; +import stripeRoutes from './routes/stripe'; const fastify = Fastify({ logger: { @@ -26,6 +27,27 @@ const fastify = Fastify({ bodyLimit: 10 * 1024 * 1024, // 10 MB }); +// Parser JSON custom : on stashe le body brut sur la request pour permettre +// la vérification de signature du webhook Stripe (qui exige les bytes exacts). +// Impact: ~2x mémoire sur les requêtes JSON, négligeable à notre échelle. +fastify.addContentTypeParser( + 'application/json', + { parseAs: 'string' }, + (req, body, done) => { + try { + if (typeof body === 'string') { + req.rawBody = body; + const json = body.length > 0 ? JSON.parse(body) : {}; + done(null, json); + } else { + done(null, body); + } + } catch (err) { + done(err as Error, undefined); + } + } +); + const prisma = new PrismaClient(); fastify.decorate('prisma', prisma); @@ -42,6 +64,9 @@ async function bootstrap(): Promise { await fastify.register(rateLimit, { max: 100, timeWindow: '1 minute', + // Les webhooks Stripe peuvent arriver en rafale sur une retry burst — + // on ne veut pas les rate-limiter et risquer des événements perdus. + skip: (req) => req.url === '/stripe/webhook', }); const allowedOrigins = ( @@ -79,6 +104,7 @@ async function bootstrap(): Promise { await fastify.register(authRoutes, { prefix: '/auth' }); await fastify.register(recipesRoutes, { prefix: '/recipes' }); await fastify.register(usersRoutes, { prefix: '/users' }); + await fastify.register(stripeRoutes, { prefix: '/stripe' }); fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() })); diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts index ea95b03..19531d4 100644 --- a/backend/src/types/fastify.d.ts +++ b/backend/src/types/fastify.d.ts @@ -13,6 +13,13 @@ declare module '@fastify/jwt' { } } +declare module 'fastify' { + interface FastifyRequest { + /** Body brut préservé pour la vérification de signature Stripe webhook */ + rawBody?: string; + } +} + export interface RecipeData { titre: string; ingredients: string | string[]; diff --git a/backend/src/utils/env.ts b/backend/src/utils/env.ts index cb7e6dd..b2e4f40 100644 --- a/backend/src/utils/env.ts +++ b/backend/src/utils/env.ts @@ -5,6 +5,9 @@ const REQUIRED = ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY'] as const; const OPTIONAL_WARN = [ 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'STRIPE_PRICE_ID_ESSENTIAL', + 'STRIPE_PRICE_ID_PREMIUM', 'MINIO_ENDPOINT', 'MINIO_PORT', 'MINIO_ACCESS_KEY', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c60b131..36fefe3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import RecipeForm from '@/pages/Recipes/RecipeForm' import Profile from './pages/Profile' import Home from './pages/Home' import ResetPassword from '@/pages/ResetPassword' +import Pricing from '@/pages/Pricing' +import CheckoutSuccess from '@/pages/CheckoutSuccess' import { MainLayout } from './layouts/MainLayout' import useAuth from '@/hooks/useAuth' import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards' @@ -40,6 +42,10 @@ function App() { {/* Profil */} } /> + {/* Abonnement */} + } /> + } /> + {/* Racine */} : } /> diff --git a/frontend/src/api/stripe.ts b/frontend/src/api/stripe.ts new file mode 100644 index 0000000..b508df5 --- /dev/null +++ b/frontend/src/api/stripe.ts @@ -0,0 +1,45 @@ +import { apiService } from "./base"; + +export interface StripePlan { + id: "essential" | "premium"; + name: string; + description: string; + monthlyRecipes: number | null; + features: string[]; + available: boolean; +} + +export interface SubscriptionStatus { + plan: "free" | "essential" | "premium"; + status: string | null; + currentPeriodEnd: string | null; + hasActiveSubscription: boolean; +} + +const stripeService = { + /** Récupère la liste des plans disponibles */ + getPlans: (): Promise<{ plans: StripePlan[] }> => apiService.get("/stripe/plans"), + + /** Récupère le statut d'abonnement de l'utilisateur courant */ + getSubscription: (): Promise => + apiService.get("/stripe/subscription"), + + /** Démarre un Checkout Stripe — redirige vers l'URL renvoyée */ + startCheckout: async (plan: "essential" | "premium"): Promise => { + const { url } = await apiService.post<{ url: string; sessionId: string }>( + "/stripe/checkout", + { plan } + ); + if (!url) throw new Error("Pas d'URL de checkout reçue"); + window.location.href = url; + }, + + /** Ouvre le Customer Portal pour gérer l'abonnement */ + openPortal: async (): Promise => { + const { url } = await apiService.post<{ url: string }>("/stripe/portal", {}); + if (!url) throw new Error("Pas d'URL de portail reçue"); + window.location.href = url; + }, +}; + +export default stripeService; diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 022f858..8d7bfcd 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react" import { Link, useLocation } from "react-router-dom" import { Button } from "@/components/ui/button" -import { Menu, X, LogOut, User, Heart, Home, BookOpen } from "lucide-react" +import { Menu, X, LogOut, User, Home, BookOpen, Sparkles } from "lucide-react" import { cn } from "@/lib/utils" import { motion, AnimatePresence } from "framer-motion" import { Logo } from "@/components/illustrations/Logo" @@ -40,8 +40,7 @@ export function Header() { const navItems = [ { name: "Accueil", path: "/", icon: Home, public: true }, { name: "Recettes", path: "/recipes", icon: BookOpen, public: true }, - // { name: "Mes recettes", path: "/recipes", icon: BookOpen, public: false }, - // { name: "Favoris", path: "/favorites", icon: Heart, public: false }, + { name: "Tarifs", path: "/pricing", icon: Sparkles, public: true }, { name: "Profil", path: "/profile", icon: User, public: false }, ] diff --git a/frontend/src/pages/CheckoutSuccess.tsx b/frontend/src/pages/CheckoutSuccess.tsx new file mode 100644 index 0000000..93073eb --- /dev/null +++ b/frontend/src/pages/CheckoutSuccess.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react" +import { Link, useNavigate } from "react-router-dom" +import { motion } from "framer-motion" +import { CheckCircle2, Sparkles, ArrowRight, ChefHat } from "lucide-react" +import { Button } from "@/components/ui/button" +import stripeService, { type SubscriptionStatus } from "@/api/stripe" + +export default function CheckoutSuccess() { + const navigate = useNavigate() + const [subscription, setSubscription] = useState(null) + const [polling, setPolling] = useState(true) + + // Poll /stripe/subscription jusqu'à ce que le webhook ait activé l'abo + // (max 10 tentatives × 1.5s = 15s, ce qui couvre largement la latence webhook) + useEffect(() => { + let attempts = 0 + const maxAttempts = 10 + const interval = setInterval(async () => { + attempts++ + try { + const sub = await stripeService.getSubscription() + setSubscription(sub) + if (sub.hasActiveSubscription || attempts >= maxAttempts) { + clearInterval(interval) + setPolling(false) + } + } catch { + if (attempts >= maxAttempts) { + clearInterval(interval) + setPolling(false) + } + } + }, 1500) + return () => clearInterval(interval) + }, []) + + const planName = + subscription?.plan === "essential" + ? "Essentiel" + : subscription?.plan === "premium" + ? "Premium" + : "Premium" + + return ( +
+ {/* Icône succès animée */} + + {/* Halo */} + + {/* Étincelles */} + {[0, 1, 2, 3].map((i) => ( + + + + ))} + {/* Check principal */} +
+ +
+
+ + +

+ Bienvenue dans{" "} + {planName} + {" "} + +

+

+ Ton abonnement est actif. Tu peux dès maintenant profiter de toutes les + fonctionnalités premium. +

+
+ + {polling && !subscription?.hasActiveSubscription && ( + + Activation de ton abonnement… + + )} + + + + + + + + + + Un reçu a été envoyé à ton adresse email. + +
+ ) +} diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx new file mode 100644 index 0000000..4cf665f --- /dev/null +++ b/frontend/src/pages/Pricing.tsx @@ -0,0 +1,302 @@ +import { useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" +import { motion } from "framer-motion" +import { Check, Sparkles, Zap, ArrowLeft, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import stripeService, { type StripePlan, type SubscriptionStatus } from "@/api/stripe" +import useAuth from "@/hooks/useAuth" + +export default function Pricing() { + const navigate = useNavigate() + const { isAuthenticated } = useAuth() + const [plans, setPlans] = useState([]) + const [subscription, setSubscription] = useState(null) + const [loading, setLoading] = useState(true) + const [checkoutLoading, setCheckoutLoading] = useState(null) + const [error, setError] = useState("") + + useEffect(() => { + const load = async () => { + try { + const [{ plans }, sub] = await Promise.all([ + stripeService.getPlans(), + isAuthenticated + ? stripeService.getSubscription().catch(() => null) + : Promise.resolve(null), + ]) + setPlans(plans) + setSubscription(sub) + } catch { + setError("Impossible de charger les plans") + } finally { + setLoading(false) + } + } + load() + }, [isAuthenticated]) + + const handleCheckout = async (planId: "essential" | "premium") => { + if (!isAuthenticated) { + navigate("/auth/login") + return + } + setCheckoutLoading(planId) + setError("") + try { + await stripeService.startCheckout(planId) + // Redirection gérée par startCheckout + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur de paiement") + setCheckoutLoading(null) + } + } + + // Plan statique "Gratuit" (pas dans les plans Stripe) + const freePlan = { + id: "free" as const, + name: "Gratuit", + description: "Pour découvrir Freedge", + features: [ + "5 recettes au total", + "Reconnaissance vocale", + "Recettes personnalisées", + "Sauvegarde des recettes", + ], + } + + const currentPlan = subscription?.plan || "free" + + return ( +
+ {/* Top bar */} +
+ +
+ + {/* Hero */} + +
+ + Choisis ton plan +
+

+ Trouve le plan qui te va +

+

+ Change ou annule à tout moment. Pas d'engagement, pas de frais cachés. +

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Plans grid */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( +
+ {/* Plan gratuit */} + navigate("/recipes/new")} + loading={false} + index={0} + /> + + {/* Plans Stripe */} + {plans.map((plan, i) => { + const isCurrent = currentPlan === plan.id + const isPopular = plan.id === "essential" + return ( + handleCheckout(plan.id)} + loading={checkoutLoading === plan.id} + index={i + 1} + /> + ) + })} +
+ )} + + {/* FAQ / notes */} + +

+ 🔒 Les paiements sont traités de façon sécurisée par Stripe. Nous ne stockons + aucune information bancaire. +

+

+ Tu peux changer de plan ou annuler à tout moment depuis ton profil. +

+
+
+ ) +} + +// --------------------------------------------------------------------------- + +interface PlanCardProps { + plan: { + id: string + name: string + description: string + features: string[] + price: string + period?: string + cta: string + disabled: boolean + current: boolean + popular?: boolean + } + onClick: () => void + loading: boolean + index: number +} + +function PlanCard({ plan, onClick, loading, index }: PlanCardProps) { + return ( + + {plan.popular && ( +
+ + + Le plus populaire + +
+ )} + + {plan.current && ( +
+ + Plan actuel + +
+ )} + +

+ {plan.name} +

+

+ {plan.description} +

+ +
+ + {plan.price} + + {plan.period && ( + + {plan.period} + + )} +
+ +
    + {plan.features.map((feature) => ( +
  • + + + {feature} + +
  • + ))} +
+ + +
+ ) +} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 26b3c54..2a40922 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -31,11 +31,15 @@ import { Sparkles, BookOpen, AlertTriangle, + CreditCard, + Zap, } from "lucide-react"; +import { Link } from "react-router-dom"; import { Checkbox } from "@/components/ui/checkbox"; import { recipeService, Recipe } from "@/api/recipe"; import userService from "@/api/user"; +import stripeService, { type SubscriptionStatus } from "@/api/stripe"; // --------------------------------------------------------------------------- // Types & constants @@ -85,6 +89,8 @@ export default function Profile() { const [success, setSuccess] = useState(""); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deletePassword, setDeletePassword] = useState(""); + const [subscription, setSubscription] = useState(null); + const [portalLoading, setPortalLoading] = useState(false); const [profileForm, setProfileForm] = useState({ name: "" }); const [passwordForm, setPasswordForm] = useState({ @@ -120,6 +126,9 @@ export default function Profile() { }); const recipes = await recipeService.getRecipes(); setUserRecipes(recipes); + + // Charge aussi l'état d'abonnement (non-bloquant) + stripeService.getSubscription().then(setSubscription).catch(() => {/* ignore */}); } catch (err) { console.error("Erreur lors du chargement du profil:", err); setError("Impossible de charger les données du profil"); @@ -250,6 +259,17 @@ export default function Profile() { navigate("/auth/login"); }; + const handleOpenPortal = async () => { + setPortalLoading(true); + try { + await stripeService.openPortal(); + // Redirection gérée par openPortal + } catch (err) { + setError(err instanceof Error ? err.message : "Impossible d'ouvrir le portail de facturation"); + setPortalLoading(false); + } + }; + const handleDeleteAccount = async () => { try { await userService.deleteAccount(deletePassword); @@ -441,6 +461,13 @@ export default function Profile() { )} + {/* --- Subscription card --- */} + + {/* --- Tabs --- */} @@ -918,3 +945,136 @@ export default function Profile() {
); } + +// --------------------------------------------------------------------------- +// SubscriptionCard +// --------------------------------------------------------------------------- + +interface SubscriptionCardProps { + subscription: SubscriptionStatus | null; + onManage: () => void; + portalLoading: boolean; +} + +function SubscriptionCard({ subscription, onManage, portalLoading }: SubscriptionCardProps) { + // État de chargement + if (!subscription) { + return ( +
+ ); + } + + const isFree = subscription.plan === "free"; + const isPremium = subscription.plan === "premium"; + const planName = + subscription.plan === "essential" + ? "Essentiel" + : subscription.plan === "premium" + ? "Premium" + : "Gratuit"; + + const nextBilling = subscription.currentPeriodEnd + ? new Date(subscription.currentPeriodEnd).toLocaleDateString("fr-FR", { + day: "numeric", + month: "long", + year: "numeric", + }) + : null; + + const statusLabels: Record = { + active: "Actif", + trialing: "Période d'essai", + past_due: "Paiement en retard", + canceled: "Annulé", + incomplete: "Incomplet", + }; + + if (isFree) { + return ( + +
+
+ +
+
+

+ Passe à l'étape supérieure +

+

+ Recettes illimitées, images HD, support prioritaire. À partir de 3€/mois. +

+
+ + + +
+
+ ); + } + + // Plan payant + return ( + +
+
+ +
+
+
+

Plan {planName}

+ {subscription.status && ( + + {statusLabels[subscription.status] ?? subscription.status} + + )} +
+

+ {nextBilling + ? `Prochaine facturation le ${nextBilling}` + : "Abonnement actif"} +

+
+ +
+
+ ); +}