feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
@@ -1,25 +1,48 @@
|
||||
"""
|
||||
Authentication and User API routes
|
||||
Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, ValidationError as PydanticValidationError
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
|
||||
from models.subscription import (
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
PlanType,
|
||||
PLANS,
|
||||
CREDIT_PACKAGES,
|
||||
)
|
||||
from services.auth_service import (
|
||||
create_user, authenticate_user, get_user_by_id,
|
||||
create_access_token, create_refresh_token, verify_token,
|
||||
check_usage_limits, update_user
|
||||
create_user,
|
||||
authenticate_user,
|
||||
get_user_by_id,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_token,
|
||||
revoke_token_jti,
|
||||
check_usage_limits,
|
||||
update_user,
|
||||
get_user_by_email,
|
||||
verify_password,
|
||||
PASSLIB_AVAILABLE,
|
||||
)
|
||||
from services.payment_service import (
|
||||
create_checkout_session, create_credits_checkout,
|
||||
cancel_subscription, get_billing_portal_url,
|
||||
handle_webhook, is_stripe_configured
|
||||
create_checkout_session,
|
||||
create_credits_checkout,
|
||||
cancel_subscription,
|
||||
get_billing_portal_url,
|
||||
handle_webhook,
|
||||
is_stripe_configured,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@@ -44,31 +67,26 @@ class CreditsCheckoutRequest(BaseModel):
|
||||
package_index: int
|
||||
|
||||
|
||||
# Dependency to get current user
|
||||
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
):
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
return user
|
||||
return get_user_by_id(payload.get("sub"))
|
||||
|
||||
|
||||
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -81,6 +99,7 @@ def user_to_response(user) -> UserResponse:
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
plan=user.plan,
|
||||
tier=user.plan,
|
||||
subscription_status=user.subscription_status,
|
||||
docs_translated_this_month=user.docs_translated_this_month,
|
||||
pages_translated_this_month=user.pages_translated_this_month,
|
||||
@@ -94,188 +113,610 @@ def user_to_response(user) -> UserResponse:
|
||||
"providers": plan_limits["providers"],
|
||||
"features": plan_limits["features"],
|
||||
"api_access": plan_limits.get("api_access", False),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Auth endpoints
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(user_create: UserCreate):
|
||||
"""Register a new user"""
|
||||
# ============== API v1 Router ==============
|
||||
|
||||
router_v1 = APIRouter(prefix="/api/v1/auth", tags=["Authentication v1"])
|
||||
|
||||
|
||||
def _has_email_validation_error(exc: PydanticValidationError) -> bool:
|
||||
"""Return True when pydantic validation errors include the email field."""
|
||||
for err in exc.errors():
|
||||
loc = err.get("loc", ())
|
||||
if isinstance(loc, (list, tuple)) and "email" in loc:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
summary="Inscription d'un nouvel utilisateur",
|
||||
description="""
|
||||
Créer un nouveau compte utilisateur.
|
||||
|
||||
**Paramètres requis:**
|
||||
- `email`: Adresse email valide (sera utilisée pour la connexion)
|
||||
- `password`: Mot de passe (minimum 8 caractères)
|
||||
- `name`: Nom complet (optionnel)
|
||||
|
||||
**Réponse:**
|
||||
- HTTP 201 avec les données de l'utilisateur créé
|
||||
- Le `tier` par défaut est "free"
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `INVALID_EMAIL`: Format d'email invalide
|
||||
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
|
||||
- `INVALID_REQUEST`: Données d'inscription invalides
|
||||
""",
|
||||
responses={
|
||||
201: {
|
||||
"description": "Utilisateur créé avec succès",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": {
|
||||
"id": "usr_abc123def456",
|
||||
"email": "utilisateur@exemple.com",
|
||||
"tier": "free",
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
400: {
|
||||
"description": "Erreur de validation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"INVALID_EMAIL": {
|
||||
"summary": "Format d'email invalide",
|
||||
"value": {
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
},
|
||||
},
|
||||
"EMAIL_EXISTS": {
|
||||
"summary": "Email déjà utilisé",
|
||||
"value": {
|
||||
"error": "EMAIL_EXISTS",
|
||||
"message": "Un compte existe déjà avec cette adresse email",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def register_v1(request: Request):
|
||||
"""Inscription d'un nouvel utilisateur (API v1) — retourne 201 avec données utilisateur"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
user_create = UserCreate.model_validate(body)
|
||||
except PydanticValidationError as exc:
|
||||
if _has_email_validation_error(exc):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
},
|
||||
)
|
||||
# Check if it's a password validation error
|
||||
for error in exc.errors():
|
||||
loc = error.get("loc", ())
|
||||
if "password" in loc:
|
||||
msg = error.get("msg", "")
|
||||
# If password is missing entirely, return INVALID_REQUEST
|
||||
if "Field required" in msg or "required" in msg.lower():
|
||||
break
|
||||
# Otherwise it's a weak password
|
||||
# Translate common pydantic messages to French
|
||||
if "at least 8 characters" in msg.lower() or "8 caractères" in msg:
|
||||
msg = "Le mot de passe doit contenir au moins 8 caractères"
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "WEAK_PASSWORD",
|
||||
"message": msg,
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Données d'inscription invalides",
|
||||
},
|
||||
)
|
||||
|
||||
# In production, registration must rely on passlib[bcrypt] hashing.
|
||||
if (
|
||||
os.getenv("ENVIRONMENT", "development").lower() == "production"
|
||||
and not PASSLIB_AVAILABLE
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "AUTH_HASHING_UNAVAILABLE",
|
||||
"message": "Service d'inscription temporairement indisponible",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
user = create_user(user_create)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
except ValueError as exc:
|
||||
msg = str(exc).strip().lower()
|
||||
if "email already registered" in msg or "email already" in msg:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "EMAIL_EXISTS",
|
||||
"message": "Un compte existe déjà avec cette adresse email",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "REGISTRATION_FAILED",
|
||||
"message": "Impossible de créer le compte avec les données fournies",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"tier": user.plan.value,
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(credentials: UserLogin):
|
||||
"""Login with email and password"""
|
||||
user = authenticate_user(credentials.email, credentials.password)
|
||||
@router_v1.post(
|
||||
"/logout",
|
||||
summary="Déconnexion utilisateur",
|
||||
description="""
|
||||
Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
|
||||
**Authentification requise:** Bearer token dans le header Authorization
|
||||
|
||||
**Paramètres optionnels:**
|
||||
- `refresh_token`: Peut être fourni pour révoquer également le refresh token
|
||||
|
||||
**Réponse:**
|
||||
- HTTP 200 avec message de confirmation
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `TOKEN_MISSING`: Token d'authentification manquant
|
||||
- `TOKEN_INVALID`: Token invalide ou expiré
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Déconnexion réussie",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"data": {"message": "Déconnexion réussie"}, "meta": {}}
|
||||
}
|
||||
},
|
||||
},
|
||||
401: {
|
||||
"description": "Erreur d'authentification",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"TOKEN_MISSING": {
|
||||
"summary": "Token manquant",
|
||||
"value": {
|
||||
"error": "TOKEN_MISSING",
|
||||
"message": "Token d'authentification requis",
|
||||
},
|
||||
},
|
||||
"TOKEN_INVALID": {
|
||||
"summary": "Token invalide",
|
||||
"value": {
|
||||
"error": "TOKEN_INVALID",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def logout_v1(request: Request):
|
||||
"""Logout utilisateur (API v1) — révoque l'access token et optionnellement le refresh token"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_MISSING",
|
||||
"message": "Token d'authentification requis",
|
||||
},
|
||||
)
|
||||
access_token = auth_header[7:]
|
||||
|
||||
payload = verify_token(access_token)
|
||||
if not payload:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_INVALID",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti:
|
||||
revoke_token_jti(jti, float(payload.get("exp", 0)))
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
refresh_token = body.get("refresh_token")
|
||||
if refresh_token:
|
||||
refresh_payload = verify_token(refresh_token)
|
||||
if refresh_payload and refresh_payload.get("jti"):
|
||||
revoke_token_jti(
|
||||
refresh_payload["jti"],
|
||||
float(refresh_payload.get("exp", 0)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/login",
|
||||
summary="Connexion utilisateur",
|
||||
description="""
|
||||
Authentifie un utilisateur et retourne les tokens JWT.
|
||||
|
||||
**Paramètres requis:**
|
||||
- `email`: Adresse email de l'utilisateur
|
||||
- `password`: Mot de passe
|
||||
|
||||
**Réponse:**
|
||||
- HTTP 200 avec `access_token` (expire dans 15 min) et `refresh_token` (expire dans 7 jours)
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `INVALID_REQUEST`: Corps de requête JSON invalide
|
||||
- `INVALID_EMAIL`: Format d'email invalide
|
||||
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Connexion réussie",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
400: {
|
||||
"description": "Erreur de validation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"INVALID_REQUEST": {
|
||||
"summary": "Corps invalide",
|
||||
"value": {
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
},
|
||||
"INVALID_EMAIL": {
|
||||
"summary": "Email invalide",
|
||||
"value": {
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
401: {
|
||||
"description": "Identifiants invalides",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def login_v1(request: Request):
|
||||
"""Login utilisateur (API v1) — retourne access_token (15min) et refresh_token (7j)"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
user_login = UserLogin.model_validate(body)
|
||||
except PydanticValidationError as exc:
|
||||
if _has_email_validation_error(exc):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Données d'inscription invalides",
|
||||
},
|
||||
)
|
||||
|
||||
user = get_user_by_email(user_login.email)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
# Constant-time dummy verify to prevent user enumeration via response time
|
||||
verify_password("__dummy__", "$2b$12$mBw4RxPJBaaS1FtEZcT/E.E35YUCk1Zx0ICzIzNUSdzHmQmko1.WW")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
},
|
||||
)
|
||||
|
||||
if not verify_password(user_login.password, user.password_hash):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
},
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
|
||||
)
|
||||
refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_tokens(request: RefreshRequest):
|
||||
"""Refresh access token"""
|
||||
payload = verify_token(request.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
@router_v1.post("/refresh")
|
||||
async def refresh_v1(request: Request):
|
||||
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
refresh_token = body.get("refresh_token")
|
||||
if (
|
||||
not refresh_token
|
||||
or not isinstance(refresh_token, str)
|
||||
or not refresh_token.strip()
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Refresh token requis",
|
||||
},
|
||||
)
|
||||
|
||||
payload = verify_token(refresh_token)
|
||||
if not payload:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
)
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
)
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
|
||||
)
|
||||
new_refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user=Depends(require_user)):
|
||||
"""Get current user info"""
|
||||
return user_to_response(user)
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage(user=Depends(require_user)):
|
||||
"""Get current usage and limits"""
|
||||
return check_usage_limits(user)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
|
||||
"""Update user settings"""
|
||||
allowed_fields = [
|
||||
"default_source_lang", "default_target_lang", "default_provider",
|
||||
"ollama_endpoint", "ollama_model", "name"
|
||||
]
|
||||
|
||||
updates = {k: v for k, v in settings.items() if k in allowed_fields}
|
||||
updated_user = update_user(user.id, updates)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(status_code=400, detail="Failed to update settings")
|
||||
|
||||
return user_to_response(updated_user)
|
||||
|
||||
|
||||
# Plans endpoint (public)
|
||||
@router.get("/plans")
|
||||
async def get_plans():
|
||||
"""Get all available plans"""
|
||||
plans = []
|
||||
for plan_type, config in PLANS.items():
|
||||
plans.append({
|
||||
"id": plan_type.value,
|
||||
"name": config["name"],
|
||||
"price_monthly": config["price_monthly"],
|
||||
"price_yearly": config["price_yearly"],
|
||||
"features": config["features"],
|
||||
"docs_per_month": config["docs_per_month"],
|
||||
"max_pages_per_doc": config["max_pages_per_doc"],
|
||||
"max_file_size_mb": config["max_file_size_mb"],
|
||||
"providers": config["providers"],
|
||||
"api_access": config.get("api_access", False),
|
||||
"popular": plan_type == PlanType.PRO,
|
||||
})
|
||||
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
|
||||
|
||||
|
||||
# Payment endpoints
|
||||
@router.post("/checkout/subscription")
|
||||
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
|
||||
"""Create Stripe checkout session for subscription"""
|
||||
if not is_stripe_configured():
|
||||
# Demo mode - just upgrade the user
|
||||
update_user(user.id, {"plan": request.plan.value})
|
||||
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
|
||||
|
||||
result = await create_checkout_session(
|
||||
user.id,
|
||||
request.plan,
|
||||
request.billing_period
|
||||
@router_v1.get(
|
||||
"/me",
|
||||
summary="Informations utilisateur",
|
||||
description="Retourne les informations de l'utilisateur authentifié.",
|
||||
)
|
||||
async def get_me_v1(user=Depends(require_user)):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": user_to_response(user).model_dump(mode="json"), "meta": {}},
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/checkout/credits")
|
||||
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
||||
"""Create Stripe checkout session for credits"""
|
||||
if not is_stripe_configured():
|
||||
# Demo mode - add credits directly
|
||||
from services.auth_service import add_credits
|
||||
credits = CREDIT_PACKAGES[request.package_index]["credits"]
|
||||
add_credits(user.id, credits)
|
||||
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
|
||||
|
||||
result = await create_credits_checkout(user.id, request.package_index)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
@router_v1.get(
|
||||
"/plans",
|
||||
summary="Liste des forfaits disponibles",
|
||||
description="Retourne tous les forfaits et packs de crédits disponibles (endpoint public).",
|
||||
)
|
||||
async def get_plans_v1():
|
||||
from models.subscription import PLANS, CREDIT_PACKAGES
|
||||
|
||||
plans_list = []
|
||||
for plan_type, plan in PLANS.items():
|
||||
plans_list.append(
|
||||
{
|
||||
"id": plan_type.value,
|
||||
"name": plan["name"],
|
||||
"price_monthly": plan["price_monthly"],
|
||||
"price_yearly": plan["price_yearly"],
|
||||
"docs_per_month": plan["docs_per_month"],
|
||||
"max_pages_per_doc": plan["max_pages_per_doc"],
|
||||
"max_file_size_mb": plan["max_file_size_mb"],
|
||||
"max_chars_per_month": plan.get("max_chars_per_month", -1),
|
||||
"providers": plan["providers"],
|
||||
"features": plan["features"],
|
||||
"ai_translation": plan.get("ai_translation", False),
|
||||
"ai_tier": plan.get("ai_tier"),
|
||||
"api_access": plan.get("api_access", False),
|
||||
"priority_processing": plan.get("priority_processing", False),
|
||||
"team_seats": plan.get("team_seats"),
|
||||
"highlight": plan.get("highlight"),
|
||||
"description": plan.get("description"),
|
||||
"badge": plan.get("badge"),
|
||||
"popular": plan.get("badge") == "POPULAIRE",
|
||||
}
|
||||
)
|
||||
|
||||
packages_list = []
|
||||
for pkg in CREDIT_PACKAGES:
|
||||
packages_list.append(
|
||||
{
|
||||
"credits": pkg["credits"],
|
||||
"price": pkg["price"],
|
||||
"price_per_credit": round(pkg["price"] / pkg["credits"], 4),
|
||||
"popular": pkg.get("popular", False),
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"plans": plans_list, "credit_packages": packages_list}, "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscription/cancel")
|
||||
async def cancel_user_subscription(user=Depends(require_user)):
|
||||
"""Cancel subscription"""
|
||||
result = await cancel_subscription(user.id)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
@router_v1.get(
|
||||
"/usage",
|
||||
summary="Utilisation et limites",
|
||||
description="Retourne l'utilisation actuelle et les limites du plan de l'utilisateur.",
|
||||
)
|
||||
async def get_usage_v1(user=Depends(require_user)):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": check_usage_limits(user), "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/billing-portal")
|
||||
async def get_billing_portal(user=Depends(require_user)):
|
||||
"""Get Stripe billing portal URL"""
|
||||
@router_v1.get(
|
||||
"/billing-portal",
|
||||
summary="Portail de facturation",
|
||||
description="Retourne l'URL du portail de facturation Stripe.",
|
||||
)
|
||||
async def get_billing_portal_v1(user=Depends(require_user)):
|
||||
url = await get_billing_portal_url(user.id)
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Billing portal not available")
|
||||
|
||||
return {"url": url}
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "BILLING_UNAVAILABLE",
|
||||
"message": "Portail de facturation non disponible",
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
||||
|
||||
|
||||
# Stripe webhook
|
||||
@router.post("/webhook/stripe")
|
||||
# ============== Stripe webhook (versioned) ==============
|
||||
|
||||
|
||||
@router_v1.post("/webhook/stripe")
|
||||
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
|
||||
"""Handle Stripe webhooks"""
|
||||
payload = await request.body()
|
||||
|
||||
|
||||
result = await handle_webhook(payload, stripe_signature or "")
|
||||
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user