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>
317 lines
16 KiB
Markdown
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).
|