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

317 lines
16 KiB
Markdown

---
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).