Files
office_translator/_bmad-output/implementation-artifacts/tech-spec-fix-stripe-plan-display-post-payment.md
Sepehr Ramezani 26bd096a06 feat: production deployment - full update with providers, admin, glossaries, pricing, tests
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>
2026-04-25 15:01:47 +02:00

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
1
2
3
4
Next.js 16 (React 19, TypeScript)
FastAPI + Pydantic v2
SQLite en dev (SQLAlchemy async), PostgreSQL en prod
Stripe SDK Python v7
TanStack Query v5
models/subscription.py
routes/auth_routes.py
services/payment_service.py
frontend/src/app/dashboard/profile/page.tsx
frontend/src/app/dashboard/page.tsx
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)
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/...`)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) -> 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 :
      cancel_at_period_end: bool = False
      
    • Action B — Dans UserResponse (classe à partir de la ligne 260), ajouter après subscription_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). 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, :
      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 :
      session = stripe.checkout.Session.retrieve(session_id)
      
      par :
      session = stripe.checkout.Session.retrieve(session_id, expand=["subscription"])
      
    • Action 3b — Dans handle_checkout_completed() (lignes 283-307), remplacer le bloc if 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), ajouter cancel_at_period_end dans le update_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: 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') :
      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.
  • 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 bloc try/finally actuel 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 le return principal :
      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: 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).