feat(stripe): full subscription flow (checkout, portal, webhooks)

Backend:
- Prisma: add stripeSubscriptionId, subscriptionStatus, priceId,
  currentPeriodEnd to User + migration SQL
- plugins/stripe.ts: getPlans catalog with env-based price IDs
- server.ts: raw body JSON parser for webhook signature verification,
  skip rate limit on /stripe/webhook
- types/fastify.d.ts: declare rawBody on FastifyRequest
- routes/stripe.ts (new):
  - GET  /stripe/plans        public
  - GET  /stripe/subscription user status
  - POST /stripe/checkout     hosted Checkout Session, lazy-creates
    customer, dynamic payment methods, promo codes enabled
  - POST /stripe/portal       Billing Portal session
  - POST /stripe/webhook      signature verified, handles
    checkout.session.completed, customer.subscription.*,
    invoice.payment_failed. Resolves user by clientReferenceId,
    metadata.userId, or stripeId fallback
- .env.example + README: Stripe setup, stripe CLI, test cards

Frontend:
- api/stripe.ts typed client (getPlans, getSubscription,
  startCheckout, openPortal)
- pages/Pricing.tsx: 3-card grid (free/essentiel/premium) with
  popular badge, current plan indicator, gradient popular card
- pages/CheckoutSuccess.tsx: animated confirmation with polling on
  /stripe/subscription until webhook activates plan
- pages/Profile.tsx: SubscriptionCard above tabs — free users see an
  upgrade banner, paid users see plan + status + next billing date
  + 'Gérer l'abonnement' button opening Customer Portal
- components/header.tsx: 'Tarifs' link in nav
- App.tsx: /pricing (public) and /checkout/success (protected) routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-08 13:54:27 +02:00
parent 0c4e0035c2
commit 339de4c44c
15 changed files with 1194 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -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");

View File

@ -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"

View File

@ -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) =>

View File

@ -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<void> {
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;

View File

@ -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<void> {
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<void> {
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() }));

View File

@ -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[];

View File

@ -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',

View File

@ -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 */}
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
{/* Abonnement */}
<Route path="/pricing" element={<Pricing />} />
<Route path="/checkout/success" element={<ProtectedRoute><CheckoutSuccess /></ProtectedRoute>} />
{/* Racine */}
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />

View File

@ -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<SubscriptionStatus> =>
apiService.get("/stripe/subscription"),
/** Démarre un Checkout Stripe — redirige vers l'URL renvoyée */
startCheckout: async (plan: "essential" | "premium"): Promise<void> => {
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<void> => {
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;

View File

@ -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 },
]

View File

@ -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<SubscriptionStatus | null>(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 (
<div className="mx-auto max-w-2xl px-4 py-12 md:py-20 text-center">
{/* Icône succès animée */}
<motion.div
className="relative mx-auto mb-8 flex h-28 w-28 items-center justify-center"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
>
{/* Halo */}
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-emerald-300/50 to-emerald-500/30 blur-2xl"
animate={{ scale: [1, 1.2, 1], opacity: [0.6, 1, 0.6] }}
transition={{ duration: 2.5, repeat: Infinity }}
/>
{/* Étincelles */}
{[0, 1, 2, 3].map((i) => (
<motion.div
key={i}
className="absolute"
style={{
top: ["0%", "0%", "100%", "100%"][i],
left: ["0%", "100%", "0%", "100%"][i],
}}
animate={{
scale: [0, 1, 0],
opacity: [0, 1, 0],
}}
transition={{
duration: 2,
repeat: Infinity,
delay: i * 0.3,
}}
>
<Sparkles className="h-4 w-4 text-amber-400" />
</motion.div>
))}
{/* Check principal */}
<div className="relative z-10 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-2xl shadow-emerald-500/40 ring-4 ring-white dark:ring-slate-900">
<CheckCircle2 className="h-14 w-14" strokeWidth={2} />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-2">
Bienvenue dans{" "}
<span className="text-warm-gradient">{planName}</span>
{" "}
<Sparkles className="inline h-6 w-6 md:h-8 md:w-8 text-amber-400" />
</h1>
<p className="text-muted-foreground text-base md:text-lg max-w-md mx-auto mt-3">
Ton abonnement est actif. Tu peux dès maintenant profiter de toutes les
fonctionnalités premium.
</p>
</motion.div>
{polling && !subscription?.hasActiveSubscription && (
<motion.p
className="mt-6 text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
<span className="animate-pulse">Activation de ton abonnement</span>
</motion.p>
)}
<motion.div
className="mt-10 flex flex-col sm:flex-row gap-3 justify-center"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<Link to="/recipes/new">
<Button
size="lg"
className="w-full sm:w-auto h-12 px-6 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md shadow-orange-500/25 hover:shadow-lg hover:shadow-orange-500/40 transition-all font-semibold group"
>
<ChefHat className="mr-2 h-5 w-5" />
Créer ma première recette
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
<Button
size="lg"
variant="outline"
className="w-full sm:w-auto h-12 px-6 rounded-full"
onClick={() => navigate("/profile")}
>
Gérer mon compte
</Button>
</motion.div>
<motion.p
className="mt-12 text-xs text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
Un reçu a é envoyé à ton adresse email.
</motion.p>
</div>
)
}

View File

@ -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<StripePlan[]>([])
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null)
const [loading, setLoading] = useState(true)
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(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 (
<div className="mx-auto max-w-6xl px-4 md:px-8 py-6 md:py-12">
{/* Top bar */}
<div className="mb-8 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="rounded-full -ml-2 gap-1.5 hover:bg-white/60 dark:hover:bg-slate-800/60"
onClick={() => navigate(-1)}
>
<ArrowLeft className="h-4 w-4" />
Retour
</Button>
</div>
{/* Hero */}
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-orange-100 to-amber-100 dark:from-orange-900/40 dark:to-amber-900/40 px-3 py-1 text-xs font-medium text-orange-700 dark:text-orange-300 border border-orange-200/60 dark:border-orange-800/40 mb-4">
<Sparkles className="h-3 w-3" />
Choisis ton plan
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">
Trouve le plan qui te va
</h1>
<p className="text-muted-foreground mt-3 max-w-xl mx-auto text-base md:text-lg">
Change ou annule à tout moment. Pas d'engagement, pas de frais cachés.
</p>
</motion.div>
{error && (
<div className="mb-6 max-w-md mx-auto rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-800 text-center">
{error}
</div>
)}
{/* Plans grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-[500px] rounded-2xl bg-muted/60 animate-pulse"
/>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Plan gratuit */}
<PlanCard
plan={{
...freePlan,
price: "0€",
cta: currentPlan === "free" ? "Plan actuel" : "Rester gratuit",
disabled: currentPlan === "free",
current: currentPlan === "free",
}}
onClick={() => navigate("/recipes/new")}
loading={false}
index={0}
/>
{/* Plans Stripe */}
{plans.map((plan, i) => {
const isCurrent = currentPlan === plan.id
const isPopular = plan.id === "essential"
return (
<PlanCard
key={plan.id}
plan={{
...plan,
price: plan.id === "essential" ? "3€" : "5€",
period: "/mois",
cta: isCurrent ? "Plan actuel" : `Choisir ${plan.name}`,
disabled: isCurrent || !plan.available,
current: isCurrent,
popular: isPopular,
}}
onClick={() => handleCheckout(plan.id)}
loading={checkoutLoading === plan.id}
index={i + 1}
/>
)
})}
</div>
)}
{/* FAQ / notes */}
<motion.div
className="mt-16 text-center text-sm text-muted-foreground max-w-2xl mx-auto space-y-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<p>
🔒 Les paiements sont traités de façon sécurisée par Stripe. Nous ne stockons
aucune information bancaire.
</p>
<p>
Tu peux changer de plan ou annuler à tout moment depuis ton profil.
</p>
</motion.div>
</div>
)
}
// ---------------------------------------------------------------------------
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 (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className={`relative rounded-2xl p-6 md:p-8 flex flex-col ${
plan.popular
? "bg-gradient-to-br from-orange-500 to-amber-500 text-white shadow-2xl shadow-orange-500/30 md:-translate-y-4"
: "bg-card/80 backdrop-blur-sm border border-border/60 shadow-sm"
}`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge className="bg-white text-orange-600 border-0 shadow-md px-3 py-1">
<Sparkles className="h-3 w-3 mr-1" />
Le plus populaire
</Badge>
</div>
)}
{plan.current && (
<div className="absolute top-4 right-4">
<Badge
variant="secondary"
className={
plan.popular
? "bg-white/20 text-white border-0 backdrop-blur-sm"
: "bg-emerald-50 text-emerald-700 border border-emerald-200"
}
>
Plan actuel
</Badge>
</div>
)}
<h3
className={`text-xl font-bold ${plan.popular ? "text-white" : "text-foreground"}`}
>
{plan.name}
</h3>
<p
className={`text-sm mt-1 ${plan.popular ? "text-white/80" : "text-muted-foreground"}`}
>
{plan.description}
</p>
<div className="mt-6 flex items-baseline gap-1">
<span
className={`text-5xl font-bold tracking-tight ${plan.popular ? "text-white" : ""}`}
>
{plan.price}
</span>
{plan.period && (
<span
className={`text-sm ${plan.popular ? "text-white/70" : "text-muted-foreground"}`}
>
{plan.period}
</span>
)}
</div>
<ul className="mt-6 space-y-3 flex-1">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-2 text-sm">
<Check
className={`h-5 w-5 shrink-0 ${
plan.popular ? "text-white" : "text-emerald-500"
}`}
strokeWidth={2.5}
/>
<span className={plan.popular ? "text-white/90" : "text-foreground/90"}>
{feature}
</span>
</li>
))}
</ul>
<Button
onClick={onClick}
disabled={plan.disabled || loading}
className={`mt-8 w-full h-12 rounded-full font-semibold transition-all ${
plan.popular
? "bg-white text-orange-600 hover:bg-white/90 shadow-lg"
: plan.current
? "bg-muted text-muted-foreground cursor-default hover:bg-muted"
: "bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md hover:shadow-lg"
}`}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Redirection
</>
) : (
<>
{plan.cta}
{!plan.disabled && !plan.popular && <Zap className="ml-2 h-4 w-4" />}
</>
)}
</Button>
</motion.div>
)
}

View File

@ -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<SubscriptionStatus | null>(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() {
)}
</AnimatePresence>
{/* --- Subscription card --- */}
<SubscriptionCard
subscription={subscription}
onManage={handleOpenPortal}
portalLoading={portalLoading}
/>
{/* --- Tabs --- */}
<Tabs defaultValue="cuisine" className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-muted/60 backdrop-blur-sm p-1 h-auto">
@ -918,3 +945,136 @@ export default function Profile() {
</div>
);
}
// ---------------------------------------------------------------------------
// SubscriptionCard
// ---------------------------------------------------------------------------
interface SubscriptionCardProps {
subscription: SubscriptionStatus | null;
onManage: () => void;
portalLoading: boolean;
}
function SubscriptionCard({ subscription, onManage, portalLoading }: SubscriptionCardProps) {
// État de chargement
if (!subscription) {
return (
<div className="mb-6 h-24 rounded-2xl bg-muted/60 animate-pulse" />
);
}
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<string, string> = {
active: "Actif",
trialing: "Période d'essai",
past_due: "Paiement en retard",
canceled: "Annulé",
incomplete: "Incomplet",
};
if (isFree) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl border border-border/60 bg-gradient-to-br from-orange-50 via-amber-50 to-yellow-50 dark:from-orange-950/30 dark:via-amber-950/20 dark:to-yellow-950/10 p-5 md:p-6 backdrop-blur-sm"
>
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="h-12 w-12 shrink-0 rounded-xl bg-gradient-to-br from-orange-500 to-amber-500 flex items-center justify-center shadow-md shadow-orange-500/30 mx-auto md:mx-0">
<Sparkles className="h-6 w-6 text-white" />
</div>
<div className="flex-1 text-center md:text-left">
<h3 className="font-semibold text-base md:text-lg">
Passe à l'étape supérieure
</h3>
<p className="text-sm text-muted-foreground mt-0.5">
Recettes illimitées, images HD, support prioritaire. À partir de 3/mois.
</p>
</div>
<Link to="/pricing" className="w-full md:w-auto">
<Button className="w-full h-11 px-6 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md hover:shadow-lg transition-all font-semibold">
<Zap className="mr-2 h-4 w-4" />
Voir les plans
</Button>
</Link>
</div>
</motion.div>
);
}
// Plan payant
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`mb-6 rounded-2xl border p-5 md:p-6 backdrop-blur-sm ${
isPremium
? "border-amber-200/60 dark:border-amber-800/40 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20"
: "border-border/60 bg-card/80"
}`}
>
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="h-12 w-12 shrink-0 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-md shadow-orange-500/30 mx-auto md:mx-0">
<Sparkles className="h-6 w-6 text-white" />
</div>
<div className="flex-1 text-center md:text-left">
<div className="flex items-center gap-2 justify-center md:justify-start flex-wrap">
<h3 className="font-semibold text-base md:text-lg">Plan {planName}</h3>
{subscription.status && (
<Badge
variant="secondary"
className={
subscription.status === "active"
? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
: "bg-orange-100 text-orange-700 border-orange-200"
}
>
{statusLabels[subscription.status] ?? subscription.status}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{nextBilling
? `Prochaine facturation le ${nextBilling}`
: "Abonnement actif"}
</p>
</div>
<Button
variant="outline"
onClick={onManage}
disabled={portalLoading}
className="w-full md:w-auto h-11 px-6 rounded-full border-orange-200 hover:bg-orange-50 hover:border-orange-300 dark:border-orange-800/50 dark:hover:bg-orange-950/30"
>
{portalLoading ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Ouverture
</>
) : (
<>
<CreditCard className="mr-2 h-4 w-4" />
Gérer l'abonnement
</>
)}
</Button>
</div>
</motion.div>
);
}