--- title: 'Correction flux Stripe — Plan non affiché après paiement' slug: 'fix-stripe-plan-display-post-payment' created: '2026-04-11' status: 'ready-for-dev' stepsCompleted: [1, 2, 3, 4] tech_stack: - 'Next.js 16 (React 19, TypeScript)' - 'FastAPI + Pydantic v2' - 'SQLite en dev (SQLAlchemy async), PostgreSQL en prod' - 'Stripe SDK Python v7' - 'TanStack Query v5' files_to_modify: - 'models/subscription.py' - 'routes/auth_routes.py' - 'services/payment_service.py' - 'frontend/src/app/dashboard/profile/page.tsx' - 'frontend/src/app/dashboard/page.tsx' code_patterns: - 'API calls: ${API_BASE}/api/v1/... (depuis @/lib/config)' - 'user_to_response() = seul point de sérialisation User → JSON' - 'update_user() = abstraction unique JSON/DB dans auth_service.py' - 'Appels Stripe sync dans fonctions async (pattern existant)' test_patterns: - 'vitest (frontend) — tests manuels pour ce flux' --- # Tech-Spec: Correction flux Stripe — Plan non affiché après paiement **Created:** 2026-04-11 ## Overview ### Problem Statement Après un paiement Stripe réussi (mode test), l'utilisateur est redirigé vers `/dashboard?session_id=cs_test_xxx`. Le sync backend **fonctionne** (HTTP 200 confirmé dans les logs, plan mis à jour en base). Mais dès que l'utilisateur navigue vers `/dashboard/profile`, la page affiche toujours "Gratuit". **Cause racine confirmée (logs serveur) :** `profile/page.tsx` appelle `fetch('/api/v1/auth/me')` avec une **URL relative**. Avec Next.js sur `localhost:3000` et FastAPI sur `localhost:8000`, cette requête arrive sur Next.js qui n'a pas cette route → 404 silencieux → `user = null` → forfait = "Gratuit" en permanence. La page attrape toutes les erreurs sans les afficher (`catch { // ignore }`). **Bugs secondaires :** 1. `dashboard/page.tsx` ne vérifie pas le status HTTP du sync — si le sync échoue, aucune erreur n'est affichée. 2. `UserResponse` n'inclut pas `subscription_ends_at` ni `cancel_at_period_end` — la page Profil tente de les afficher mais ils sont toujours `undefined`. 3. `handle_checkout_completed` ne sauvegarde pas `subscription_ends_at` (seul le webhook `customer.subscription.updated` le fait, mais il ne s'exécute pas en dev sans `stripe listen`). ### Solution 5 tâches atomiques ordonnées par dépendance : 1. Ajouter `cancel_at_period_end` au modèle `User` + compléter `UserResponse` dans `models/subscription.py` 2. Exposer `subscription_ends_at` et `cancel_at_period_end` dans `user_to_response()` (`routes/auth_routes.py`) 3. Corriger `handle_checkout_completed` et `handle_subscription_updated` dans `payment_service.py` pour sauvegarder `subscription_ends_at` et `cancel_at_period_end` 4. **Corriger les 4 URLs relatives** dans `profile/page.tsx` → `${API_BASE}/api/v1/...` 5. Ajouter gestion d'erreur du sync dans `dashboard/page.tsx` ### Scope **In Scope:** - Fix URL relative dans `profile/page.tsx` (bug principal) - Exposition de `subscription_ends_at` et `cancel_at_period_end` dans l'API - Sauvegarde de `subscription_ends_at` lors du checkout + `cancel_at_period_end` lors de l'annulation - Gestion d'erreur du sync dans `dashboard/page.tsx` **Out of Scope:** - Refactoring de `profile/page.tsx` vers le hook `useUser` TanStack Query - Configuration Stripe CLI webhook pour dev - Tests automatisés du flux Stripe --- ## Context for Development ### Codebase Patterns - **URLs API frontend** : toujours `fetch(\`${API_BASE}/api/v1/...\`)` avec `import { API_BASE } from '@/lib/config'`. `API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'`. La page Profil est la **seule** à utiliser des URLs relatives — c'est le bug. - **Sérialisation User** : `user_to_response(user: User) -> UserResponse` dans `routes/auth_routes.py` est le seul endroit à modifier pour changer la réponse de `/api/v1/auth/me`. - **Mise à jour utilisateur** : `update_user(user_id, dict)` dans `auth_service.py` — en dev, charge `data/users.json`, merge le dict, sauvegarde. Accepte n'importe quelle clé présente dans le modèle `User`. - **Stripe SDK v7** : `.retrieve(id, expand=["subscription"])` retourne un objet Stripe où `session["subscription"]` est soit un `str` (ID seulement) soit un objet dict-like si expansé. - **`subscription_ends_at`** : champ déjà présent dans le modèle Pydantic `User` (`models/subscription.py`, ligne 211), mais jamais renseigné via le path sync. Non présent dans `UserResponse`. - **`cancel_at_period_end`** : non présent dans le modèle `User`, non présent dans `UserResponse`. À ajouter dans les deux. - **React Strict Mode** : en dev, le `useEffect` du dashboard s'exécute deux fois → deux appels sync pour le même `session_id`. Comportement normal, le flag `cancelled` gère la déduplication du `refetch()`. ### Files to Reference | File | Purpose | | ---- | ------- | | `frontend/src/app/dashboard/profile/page.tsx` | Page Profil — 4 URLs relatives à corriger (lignes 114, 115, 131, 147) | | `frontend/src/app/dashboard/page.tsx` | Dashboard — sync post-paiement, gestion d'erreur à ajouter | | `frontend/src/lib/config.ts` | Exporte `API_BASE` — à importer dans `profile/page.tsx` | | `models/subscription.py` | `User` (Pydantic) + `UserResponse` — champs à ajouter | | `routes/auth_routes.py` | `user_to_response()` — exposition des nouveaux champs | | `services/payment_service.py` | `sync_checkout_session()`, `handle_checkout_completed()`, `handle_subscription_updated()` | | `services/auth_service.py` | `update_user()` — référence seulement, pas de modification | ### Technical Decisions - **Pas de refactoring complet de `profile/page.tsx`** : la correction minimale des 4 URLs avec `API_BASE` est suffisante et non-breaking. - **`expand=["subscription"]` dans `sync_checkout_session` uniquement** : le webhook reçoit déjà l'objet subscription complet. La logique de `handle_checkout_completed` doit gérer les deux cas (string ID vs objet expansé). - **`subscription_ends_at` stocké en ISO 8601 string** dans le JSON (cohérent avec `updated_at`), Pydantic le parse en `datetime` automatiquement. - **`getattr(user, 'cancel_at_period_end', False)`** dans `user_to_response` pour rétrocompatibilité avec les users existants en JSON qui n'ont pas ce champ. --- ## Implementation Plan ### Tasks - [ ] **Task 1 — `models/subscription.py` : Ajouter les champs manquants** - File: `models/subscription.py` - Action A — Dans le modèle `User` (classe à partir de la ligne 196), ajouter après `subscription_ends_at` : ```python cancel_at_period_end: bool = False ``` - Action B — Dans `UserResponse` (classe à partir de la ligne 260), ajouter après `subscription_status` : ```python subscription_ends_at: Optional[datetime] = None cancel_at_period_end: bool = False ``` - Notes: `Optional[datetime]` est déjà importé en tête de fichier (`from typing import Optional`). `datetime` est aussi déjà importé. - [ ] **Task 2 — `routes/auth_routes.py` : Exposer les nouveaux champs dans `user_to_response()`** - File: `routes/auth_routes.py` - Action — Dans la fonction `user_to_response(user)` (lignes 94-118), ajouter deux lignes dans le constructeur `UserResponse(...)`, après `subscription_status=user.subscription_status,` : ```python subscription_ends_at=user.subscription_ends_at, cancel_at_period_end=getattr(user, 'cancel_at_period_end', False), ``` - Notes: Utiliser `getattr` avec default `False` pour la rétrocompatibilité avec les users JSON existants qui n'ont pas le champ `cancel_at_period_end`. - [ ] **Task 3 — `services/payment_service.py` : Sauvegarder `subscription_ends_at` et `cancel_at_period_end`** - File: `services/payment_service.py` - **Action 3a** — Dans `sync_checkout_session()` (ligne 169), remplacer : ```python session = stripe.checkout.Session.retrieve(session_id) ``` par : ```python session = stripe.checkout.Session.retrieve(session_id, expand=["subscription"]) ``` - **Action 3b** — Dans `handle_checkout_completed()` (lignes 283-307), remplacer le bloc `if plan:` par : ```python plan = metadata.get("plan") if plan: subscription_raw = session.get("subscription") subscription_id = None subscription_ends_at = None if isinstance(subscription_raw, str): subscription_id = subscription_raw elif subscription_raw: subscription_id = subscription_raw.get("id") period_end = subscription_raw.get("current_period_end") if period_end: subscription_ends_at = datetime.fromtimestamp(period_end).isoformat() update_user(user_id, { "plan": plan, "subscription_status": SubscriptionStatus.ACTIVE.value, "stripe_subscription_id": subscription_id, "subscription_ends_at": subscription_ends_at, "docs_translated_this_month": 0, "pages_translated_this_month": 0, }) ``` - **Action 3c** — Dans `handle_subscription_updated()` (lignes 310-334), ajouter `cancel_at_period_end` dans le `update_user` : ```python update_user(user_id, { "subscription_status": status.value, "cancel_at_period_end": subscription.get("cancel_at_period_end", False), "subscription_ends_at": datetime.fromtimestamp( subscription.get("current_period_end", 0) ).isoformat() if subscription.get("current_period_end") else None }) ``` - Notes: `datetime` est déjà importé en tête de fichier. - [ ] **Task 4 — `frontend/src/app/dashboard/profile/page.tsx` : Corriger les 4 URLs relatives** - File: `frontend/src/app/dashboard/profile/page.tsx` - **Action 4a** — Ajouter l'import `API_BASE` après les imports existants en tête de fichier (ligne 1, après `'use client'`) : ```ts import { API_BASE } from '@/lib/config'; ``` - **Action 4b** — Dans `fetchData()` (~ligne 113), remplacer les deux URLs : ```ts // AVANT fetch('/api/v1/auth/me', { headers: authHeaders }), fetch('/api/v1/auth/usage', { headers: authHeaders }), // APRÈS fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }), fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }), ``` - **Action 4c** — Dans `handleBillingPortal()` (~ligne 131), remplacer : ```ts // AVANT const res = await fetch('/api/v1/auth/billing-portal', { headers: authHeaders }); // APRÈS const res = await fetch(`${API_BASE}/api/v1/auth/billing-portal`, { headers: authHeaders }); ``` - **Action 4d** — Dans `handleCancel()` (~ligne 147), remplacer : ```ts // AVANT const res = await fetch('/api/v1/auth/cancel-subscription', { method: 'POST', headers: authHeaders, }); // APRÈS const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, { method: 'POST', headers: authHeaders, }); ``` - Notes: `API_BASE` = `process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'`. En prod, la variable d'env pointe vers le bon domaine. - [ ] **Task 5 — `frontend/src/app/dashboard/page.tsx` : Gestion d'erreur du sync Stripe** - File: `frontend/src/app/dashboard/page.tsx` - **Action 5a** — Ajouter un état d'erreur dans le composant (après les états existants ligne 16-17) : ```ts const [syncError, setSyncError] = useState(null); ``` - **Action 5b** — Dans `runSync()`, remplacer le bloc `try/finally` actuel par : ```ts const runSync = async () => { setSyncingCheckout(true); setSyncError(null); try { const res = await fetch( `${API_BASE}/api/v1/auth/checkout/sync?session_id=${encodeURIComponent(checkoutSessionId)}`, { headers: { Authorization: `Bearer ${token}` } } ); if (!cancelled) { if (!res.ok) { const errData = await res.json().catch(() => ({})); setSyncError(errData.message || 'Erreur lors de la synchronisation du paiement.'); } else { await refetch(); router.replace('/dashboard'); } } } catch { if (!cancelled) setSyncError('Erreur réseau. Veuillez rafraîchir la page.'); } finally { if (!cancelled) setSyncingCheckout(false); } }; ``` - **Action 5c** — Dans le JSX, après le bloc `if (isLoading || syncingCheckout)`, ajouter avant le `return` principal : ```tsx if (syncError) { return (

{syncError}

); } ``` - Notes: `setSyncError` ne doit être appelé que si `!cancelled` pour éviter les updates sur un composant démonté. --- ### Acceptance Criteria - [ ] **AC 1 — Correction URL (bug principal)** Given que l'utilisateur est connecté et que le frontend tourne sur `localhost:3000` (Next.js) séparé du backend `localhost:8000` (FastAPI), When il navigue vers `/dashboard/profile`, Then les logs FastAPI montrent `GET /api/v1/auth/me 200 OK` pour la page Profil (la requête atteint le backend), et les infos utilisateur (nom, email, forfait réel) s'affichent correctement. - [ ] **AC 2 — Plan mis à jour après paiement** Given que l'utilisateur était sur le forfait "Gratuit", When il paie le forfait "Starter" sur Stripe (carte test `4242 4242 4242 4242`), est redirigé vers `/dashboard?session_id=cs_test_xxx`, puis navigue vers `/dashboard/profile`, Then la page Profil affiche le badge "Starter" (avec couleur bleue) et non "Gratuit". - [ ] **AC 3 — Date de renouvellement affichée** Given que l'utilisateur a un abonnement actif payant, When la page Profil charge, Then la section "Mon abonnement" affiche la date de renouvellement (ex: "Renouvellement le 11 mai 2026") si `subscription_ends_at` est renseigné. - [ ] **AC 4 — Gestion d'erreur sync** Given que le checkout sync échoue pour une raison quelconque (ex: session invalide), When l'utilisateur est redirigé vers `/dashboard?session_id=xxx`, Then un message d'erreur explicite s'affiche (ex: "Erreur lors de la synchronisation du paiement.") avec un lien "Continuer vers le tableau de bord", au lieu d'une page silencieusement incorrecte. - [ ] **AC 5 — Pas de régression** Given que l'utilisateur est sur le forfait gratuit sans avoir payé, When il navigue vers `/dashboard/profile`, Then la page affiche correctement son nom, son email et "Gratuit" comme forfait (les données chargent normalement — pas de régression). --- ## Additional Context ### Dependencies Aucune nouvelle dépendance NPM ou Python. Seul ajout d'import : - `frontend/src/app/dashboard/profile/page.tsx` : `import { API_BASE } from '@/lib/config'` ### Testing Strategy **Manuel (séquence à suivre dans l'ordre) :** 1. Lancer le backend : `uvicorn main:app --port 8000 --reload` (dans le dossier racine) 2. Lancer le frontend : `npm run dev` (dans `frontend/`) 3. Ouvrir `http://localhost:3000`, se connecter 4. **Tester AC 5 d'abord** : aller sur `/dashboard/profile` → vérifier que le nom et l'email s'affichent (avant, ils ne s'affichaient pas) 5. Aller sur `/pricing`, choisir Starter, payer avec `4242 4242 4242 4242` (date: n'importe quelle future, CVV: 123) 6. Vérifier le spinner sur `/dashboard` pendant le sync 7. Naviguer vers `/dashboard/profile` → vérifier le badge "Starter" 8. Vérifier dans les logs FastAPI que `GET /api/v1/auth/me` est appelé depuis la page Profil ### Notes - **Idempotence du sync** : en mode dev (React Strict Mode), le sync est appelé deux fois pour le même `session_id`. Les deux retournent 200 et appliquent les mêmes updates — idempotent, pas de problème. - **Stripe test cards** : `4242 4242 4242 4242` (succès), `4000 0000 0000 0002` (déclinée, pour tester AC 4). - **Production** : si frontend et backend sont sur le même domaine (via nginx), les URLs relatives fonctionneraient quand même — mais la correction avec `API_BASE` est plus robuste et correcte dans tous les cas. - **`subscription_ends_at` null après paiement** : si Stripe ne retourne pas de `current_period_end` dans l'objet expansé, le champ reste `null` et la section "Renouvellement" ne s'affiche pas (comportement existant, pas de régression).