---
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<string | null>(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 (
        <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).
