Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
title, slug, created, status, stepsCompleted, tech_stack, files_to_modify, code_patterns, test_patterns
| title | slug | created | status | stepsCompleted | tech_stack | files_to_modify | code_patterns | test_patterns | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Correction flux Stripe — Plan non affiché après paiement | fix-stripe-plan-display-post-payment | 2026-04-11 | ready-for-dev |
|
|
|
|
|
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 :
dashboard/page.tsxne vérifie pas le status HTTP du sync — si le sync échoue, aucune erreur n'est affichée.UserResponsen'inclut passubscription_ends_atnicancel_at_period_end— la page Profil tente de les afficher mais ils sont toujoursundefined.handle_checkout_completedne sauvegarde passubscription_ends_at(seul le webhookcustomer.subscription.updatedle fait, mais il ne s'exécute pas en dev sansstripe listen).
Solution
5 tâches atomiques ordonnées par dépendance :
- Ajouter
cancel_at_period_endau modèleUser+ compléterUserResponsedansmodels/subscription.py - Exposer
subscription_ends_atetcancel_at_period_enddansuser_to_response()(routes/auth_routes.py) - Corriger
handle_checkout_completedethandle_subscription_updateddanspayment_service.pypour sauvegardersubscription_ends_atetcancel_at_period_end - Corriger les 4 URLs relatives dans
profile/page.tsx→${API_BASE}/api/v1/... - 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_atetcancel_at_period_enddans l'API - Sauvegarde de
subscription_ends_atlors du checkout +cancel_at_period_endlors de l'annulation - Gestion d'erreur du sync dans
dashboard/page.tsx
Out of Scope:
- Refactoring de
profile/page.tsxvers le hookuseUserTanStack 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/...`)avecimport { 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) -> UserResponsedansroutes/auth_routes.pyest le seul endroit à modifier pour changer la réponse de/api/v1/auth/me. - Mise à jour utilisateur :
update_user(user_id, dict)dansauth_service.py— en dev, chargedata/users.json, merge le dict, sauvegarde. Accepte n'importe quelle clé présente dans le modèleUser. - Stripe SDK v7 :
.retrieve(id, expand=["subscription"])retourne un objet Stripe oùsession["subscription"]est soit unstr(ID seulement) soit un objet dict-like si expansé. subscription_ends_at: champ déjà présent dans le modèle PydanticUser(models/subscription.py, ligne 211), mais jamais renseigné via le path sync. Non présent dansUserResponse.cancel_at_period_end: non présent dans le modèleUser, non présent dansUserResponse. À ajouter dans les deux.- React Strict Mode : en dev, le
useEffectdu dashboard s'exécute deux fois → deux appels sync pour le mêmesession_id. Comportement normal, le flagcancelledgère la déduplication durefetch().
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 avecAPI_BASEest suffisante et non-breaking. expand=["subscription"]danssync_checkout_sessionuniquement : le webhook reçoit déjà l'objet subscription complet. La logique dehandle_checkout_completeddoit gérer les deux cas (string ID vs objet expansé).subscription_ends_atstocké en ISO 8601 string dans le JSON (cohérent avecupdated_at), Pydantic le parse endatetimeautomatiquement.getattr(user, 'cancel_at_period_end', False)dansuser_to_responsepour 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èssubscription_ends_at:cancel_at_period_end: bool = False - Action B — Dans
UserResponse(classe à partir de la ligne 260), ajouter aprèssubscription_status: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).datetimeest aussi déjà importé.
- File:
-
Task 2 —
routes/auth_routes.py: Exposer les nouveaux champs dansuser_to_response()- File:
routes/auth_routes.py - Action — Dans la fonction
user_to_response(user)(lignes 94-118), ajouter deux lignes dans le constructeurUserResponse(...), aprèssubscription_status=user.subscription_status,:subscription_ends_at=user.subscription_ends_at, cancel_at_period_end=getattr(user, 'cancel_at_period_end', False), - Notes: Utiliser
getattravec defaultFalsepour la rétrocompatibilité avec les users JSON existants qui n'ont pas le champcancel_at_period_end.
- File:
-
Task 3 —
services/payment_service.py: Sauvegardersubscription_ends_atetcancel_at_period_end- File:
services/payment_service.py - Action 3a — Dans
sync_checkout_session()(ligne 169), remplacer :par :session = stripe.checkout.Session.retrieve(session_id)session = stripe.checkout.Session.retrieve(session_id, expand=["subscription"]) - Action 3b — Dans
handle_checkout_completed()(lignes 283-307), remplacer le blocif plan:par :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), ajoutercancel_at_period_enddans leupdate_user: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:
datetimeest déjà importé en tête de fichier.
- File:
-
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_BASEaprès les imports existants en tête de fichier (ligne 1, après'use client') :import { API_BASE } from '@/lib/config'; - Action 4b — Dans
fetchData()(~ligne 113), remplacer les deux URLs :// 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 :// 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 :// 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.
- File:
-
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) :
const [syncError, setSyncError] = useState<string | null>(null); - Action 5b — Dans
runSync(), remplacer le bloctry/finallyactuel par :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 lereturnprincipal :if (syncError) { return ( <div className="flex flex-col items-center justify-center py-12 gap-4"> <p className="text-sm text-destructive">{syncError}</p> <button onClick={() => router.replace('/dashboard')} className="text-xs text-muted-foreground underline" > Continuer vers le tableau de bord </button> </div> ); } - Notes:
setSyncErrorne doit être appelé que si!cancelledpour éviter les updates sur un composant démonté.
- File:
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 backendlocalhost:8000(FastAPI), When il navigue vers/dashboard/profile, Then les logs FastAPI montrentGET /api/v1/auth/me 200 OKpour 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_atest 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) :
- Lancer le backend :
uvicorn main:app --port 8000 --reload(dans le dossier racine) - Lancer le frontend :
npm run dev(dansfrontend/) - Ouvrir
http://localhost:3000, se connecter - 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) - Aller sur
/pricing, choisir Starter, payer avec4242 4242 4242 4242(date: n'importe quelle future, CVV: 123) - Vérifier le spinner sur
/dashboardpendant le sync - Naviguer vers
/dashboard/profile→ vérifier le badge "Starter" - Vérifier dans les logs FastAPI que
GET /api/v1/auth/meest 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_BASEest plus robuste et correcte dans tous les cas. subscription_ends_atnull après paiement : si Stripe ne retourne pas decurrent_period_enddans l'objet expansé, le champ restenullet la section "Renouvellement" ne s'affiche pas (comportement existant, pas de régression).