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>
This commit is contained in:
@@ -14,10 +14,11 @@ from typing import Optional, Literal
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Form, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import config
|
||||
from models.subscription import PlanType, PLANS
|
||||
from services import pricing_config as pricing_cfg
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -52,18 +53,10 @@ _LOCKOUT_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
def get_redis_client():
|
||||
global _redis_client
|
||||
if _redis_client is None and REDIS_URL:
|
||||
try:
|
||||
import redis
|
||||
"""Return shared sync Redis client from core.redis, or None."""
|
||||
from core.redis import get_sync_redis
|
||||
|
||||
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
_redis_client.ping()
|
||||
logger.info("Connected to Redis for session storage")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed: {e}. Using in-memory sessions.")
|
||||
_redis_client = False
|
||||
return _redis_client if _redis_client else None
|
||||
return get_sync_redis()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
@@ -156,6 +149,30 @@ class AdminUpdateUserTierRequest(BaseModel):
|
||||
plan: Literal["free", "starter", "pro", "business", "enterprise"]
|
||||
|
||||
|
||||
class AdminResetPasswordRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
def _plan_info_for_admin(plan_raw) -> dict:
|
||||
"""Résout PLANS[...] à partir d'une valeur SQLAlchemy, string ou enum."""
|
||||
try:
|
||||
if isinstance(plan_raw, str):
|
||||
pt = PlanType(plan_raw.lower())
|
||||
elif hasattr(plan_raw, "value"):
|
||||
pt = PlanType(plan_raw.value)
|
||||
else:
|
||||
pt = PlanType(plan_raw)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pt = PlanType.FREE
|
||||
return PLANS.get(pt, PLANS[PlanType.FREE])
|
||||
|
||||
|
||||
def _subscription_status_str(raw) -> str:
|
||||
if raw is None:
|
||||
return "active"
|
||||
return raw.value if hasattr(raw, "value") else str(raw)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def admin_login(request: AdminLoginRequest, req: Request):
|
||||
"""Admin login endpoint - Returns a bearer token for authenticated admin access"""
|
||||
@@ -269,12 +286,14 @@ async def get_admin_dashboard(is_admin: bool = Depends(require_admin)):
|
||||
|
||||
@router.get("/users")
|
||||
async def get_admin_users(is_admin: bool = Depends(require_admin)):
|
||||
"""Get all users with their usage stats"""
|
||||
"""Liste tous les utilisateurs (base SQLite/PostgreSQL + comptes uniquement dans users.json)."""
|
||||
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
|
||||
from database.connection import get_sync_session
|
||||
from database.models import ApiKey
|
||||
|
||||
users_list = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_emails: set[str] = set()
|
||||
|
||||
with get_sync_session() as session:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
@@ -282,64 +301,94 @@ async def get_admin_users(is_admin: bool = Depends(require_admin)):
|
||||
|
||||
db_users = session.query(DBUser).order_by(DBUser.created_at.desc()).all()
|
||||
for db_user in db_users:
|
||||
plan = db_user.plan or "free"
|
||||
plan_info = PLANS.get(plan, PLANS["free"])
|
||||
plan_val = db_user.plan
|
||||
plan_str = (
|
||||
plan_val.value
|
||||
if hasattr(plan_val, "value")
|
||||
else (str(plan_val) if plan_val else "free")
|
||||
)
|
||||
plan_info = _plan_info_for_admin(plan_val)
|
||||
|
||||
active_api_keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == db_user.id, ApiKey.is_active == True)
|
||||
.all()
|
||||
)
|
||||
users_list.append(
|
||||
{
|
||||
"id": str(db_user.id),
|
||||
"email": db_user.email or "",
|
||||
"name": db_user.name or "",
|
||||
"plan": plan,
|
||||
"subscription_status": db_user.subscription_status or "active",
|
||||
"docs_translated_this_month": db_user.docs_translated_this_month or 0,
|
||||
"pages_translated_this_month": db_user.pages_translated_this_month or 0,
|
||||
"extra_credits": db_user.extra_credits or 0,
|
||||
"created_at": db_user.created_at.isoformat() if db_user.created_at else "",
|
||||
"plan_limits": {
|
||||
"docs_per_month": plan_info.get("docs_per_month", 0),
|
||||
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
|
||||
},
|
||||
"api_keys_count": len(active_api_keys),
|
||||
"api_key_ids": [key.id for key in active_api_keys],
|
||||
}
|
||||
)
|
||||
else:
|
||||
users_data = load_users()
|
||||
for user_id, user_data in users_data.items():
|
||||
plan = user_data.get("plan", "free")
|
||||
plan_info = PLANS.get(plan, PLANS["free"])
|
||||
row = {
|
||||
"id": str(db_user.id),
|
||||
"email": db_user.email or "",
|
||||
"name": db_user.name or "",
|
||||
"plan": plan_str,
|
||||
"subscription_status": _subscription_status_str(
|
||||
db_user.subscription_status
|
||||
),
|
||||
"docs_translated_this_month": db_user.docs_translated_this_month or 0,
|
||||
"pages_translated_this_month": db_user.pages_translated_this_month or 0,
|
||||
"extra_credits": db_user.extra_credits or 0,
|
||||
"created_at": db_user.created_at.isoformat() if db_user.created_at else "",
|
||||
"plan_limits": {
|
||||
"docs_per_month": plan_info.get("docs_per_month", 0),
|
||||
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
|
||||
},
|
||||
"api_keys_count": len(active_api_keys),
|
||||
"api_key_ids": [key.id for key in active_api_keys],
|
||||
"storage": "database",
|
||||
}
|
||||
users_list.append(row)
|
||||
seen_ids.add(row["id"])
|
||||
if row["email"]:
|
||||
seen_emails.add(row["email"].lower())
|
||||
|
||||
active_api_keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.is_active == True)
|
||||
.all()
|
||||
)
|
||||
users_list.append(
|
||||
{
|
||||
"id": user_id,
|
||||
"email": user_data.get("email", ""),
|
||||
"name": user_data.get("name", ""),
|
||||
"plan": plan,
|
||||
"subscription_status": user_data.get("subscription_status", "active"),
|
||||
"docs_translated_this_month": user_data.get("docs_translated_this_month", 0),
|
||||
"pages_translated_this_month": user_data.get("pages_translated_this_month", 0),
|
||||
"extra_credits": user_data.get("extra_credits", 0),
|
||||
"created_at": user_data.get("created_at", ""),
|
||||
"plan_limits": {
|
||||
"docs_per_month": plan_info.get("docs_per_month", 0),
|
||||
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
|
||||
},
|
||||
"api_keys_count": len(active_api_keys),
|
||||
"api_key_ids": [key.id for key in active_api_keys],
|
||||
}
|
||||
)
|
||||
users_list.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
# Comptes uniquement dans data/users.json (legacy) — absents de la table users
|
||||
users_data = load_users()
|
||||
for user_id, user_data in users_data.items():
|
||||
email = (user_data.get("email") or "").strip().lower()
|
||||
if user_id in seen_ids or (email and email in seen_emails):
|
||||
continue
|
||||
plan_raw = user_data.get("plan", "free")
|
||||
plan_str = (
|
||||
plan_raw.value
|
||||
if hasattr(plan_raw, "value")
|
||||
else str(plan_raw)
|
||||
)
|
||||
plan_info = _plan_info_for_admin(plan_str)
|
||||
|
||||
active_api_keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.is_active == True)
|
||||
.all()
|
||||
)
|
||||
users_list.append(
|
||||
{
|
||||
"id": user_id,
|
||||
"email": user_data.get("email", ""),
|
||||
"name": user_data.get("name", ""),
|
||||
"plan": plan_str,
|
||||
"subscription_status": str(
|
||||
user_data.get("subscription_status", "active")
|
||||
),
|
||||
"docs_translated_this_month": user_data.get(
|
||||
"docs_translated_this_month", 0
|
||||
),
|
||||
"pages_translated_this_month": user_data.get(
|
||||
"pages_translated_this_month", 0
|
||||
),
|
||||
"extra_credits": user_data.get("extra_credits", 0),
|
||||
"created_at": user_data.get("created_at", ""),
|
||||
"plan_limits": {
|
||||
"docs_per_month": plan_info.get("docs_per_month", 0),
|
||||
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
|
||||
},
|
||||
"api_keys_count": len(active_api_keys),
|
||||
"api_key_ids": [key.id for key in active_api_keys],
|
||||
"storage": "json_file",
|
||||
}
|
||||
)
|
||||
seen_ids.add(user_id)
|
||||
if email:
|
||||
seen_emails.add(email)
|
||||
|
||||
users_list.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
|
||||
return {"total": len(users_list), "users": users_list}
|
||||
|
||||
@@ -406,31 +455,93 @@ async def patch_admin_user_tier(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
async def admin_reset_user_password(
|
||||
user_id: str,
|
||||
body: AdminResetPasswordRequest,
|
||||
is_admin: bool = Depends(require_admin),
|
||||
):
|
||||
"""Définit un nouveau mot de passe pour un utilisateur (sans email de réinitialisation)."""
|
||||
from services.auth_service import admin_set_user_password
|
||||
|
||||
try:
|
||||
user = admin_set_user_password(user_id, body.new_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "NOT_FOUND",
|
||||
"message": "Utilisateur introuvable (id inconnu en base et dans users.json).",
|
||||
},
|
||||
)
|
||||
logger.info("admin_password_reset for user_id=%s", user_id)
|
||||
return {
|
||||
"data": {"id": user.id, "email": user.email},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(is_admin: bool = Depends(require_admin)):
|
||||
"""Get comprehensive admin statistics"""
|
||||
from services.auth_service import load_users
|
||||
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
|
||||
from services.translation_service import _translation_cache
|
||||
|
||||
users_data = load_users()
|
||||
|
||||
total_users = len(users_data)
|
||||
plan_distribution = {}
|
||||
plan_distribution: dict = {}
|
||||
total_docs_translated = 0
|
||||
total_pages_translated = 0
|
||||
active_users = 0
|
||||
|
||||
for user_data in users_data.values():
|
||||
plan = user_data.get("plan", "free")
|
||||
plan_distribution[plan] = plan_distribution.get(plan, 0) + 1
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser
|
||||
|
||||
docs = user_data.get("docs_translated_this_month", 0)
|
||||
pages = user_data.get("pages_translated_this_month", 0)
|
||||
total_docs_translated += docs
|
||||
total_pages_translated += pages
|
||||
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
with get_sync_session() as session:
|
||||
db_rows = session.query(DBUser).all()
|
||||
db_ids = {str(u.id) for u in db_rows}
|
||||
db_emails = {(u.email or "").strip().lower() for u in db_rows}
|
||||
for u in db_rows:
|
||||
pv = u.plan.value if hasattr(u.plan, "value") else str(u.plan)
|
||||
plan_distribution[pv] = plan_distribution.get(pv, 0) + 1
|
||||
docs = u.docs_translated_this_month or 0
|
||||
pages = u.pages_translated_this_month or 0
|
||||
total_docs_translated += docs
|
||||
total_pages_translated += pages
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
json_only = 0
|
||||
for uid, ud in users_data.items():
|
||||
if uid in db_ids:
|
||||
continue
|
||||
em = (ud.get("email") or "").strip().lower()
|
||||
if em and em in db_emails:
|
||||
continue
|
||||
json_only += 1
|
||||
pr = ud.get("plan", "free")
|
||||
pv = pr.value if hasattr(pr, "value") else str(pr)
|
||||
plan_distribution[pv] = plan_distribution.get(pv, 0) + 1
|
||||
docs = int(ud.get("docs_translated_this_month", 0) or 0)
|
||||
pages = int(ud.get("pages_translated_this_month", 0) or 0)
|
||||
total_docs_translated += docs
|
||||
total_pages_translated += pages
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
total_users = len(db_ids) + json_only
|
||||
else:
|
||||
total_users = len(users_data)
|
||||
for user_data in users_data.values():
|
||||
plan = user_data.get("plan", "free")
|
||||
pv = plan.value if hasattr(plan, "value") else str(plan)
|
||||
plan_distribution[pv] = plan_distribution.get(pv, 0) + 1
|
||||
docs = user_data.get("docs_translated_this_month", 0)
|
||||
pages = user_data.get("pages_translated_this_month", 0)
|
||||
total_docs_translated += docs
|
||||
total_pages_translated += pages
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
|
||||
cache_stats = _translation_cache.get_stats()
|
||||
|
||||
@@ -680,13 +791,14 @@ class ProviderSettings(BaseModel):
|
||||
|
||||
class SettingsConfig(BaseModel):
|
||||
google: ProviderSettings = ProviderSettings(enabled=True)
|
||||
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
|
||||
deepl: ProviderSettings = ProviderSettings()
|
||||
openai: ProviderSettings = ProviderSettings()
|
||||
ollama: ProviderSettings = ProviderSettings() # dev-only in UI
|
||||
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
|
||||
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
|
||||
zai: ProviderSettings = ProviderSettings()
|
||||
fallback_chain: str = "google,deepl,openai,ollama,openrouter,openrouter_premium,zai"
|
||||
fallback_chain: str = "google,google_cloud,deepl,openai,ollama,openrouter,openrouter_premium,zai"
|
||||
fallback_chain_classic: str = "google,deepl"
|
||||
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,zai,ollama"
|
||||
|
||||
@@ -754,6 +866,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
payload["deepl"] = _merge_env(settings.deepl, key_env="DEEPL_API_KEY")
|
||||
payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
|
||||
payload["ollama"] = _merge_env(settings.ollama, url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL", default_url="http://localhost:11434", default_model="llama3")
|
||||
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
|
||||
|
||||
# Inform the frontend which providers have API keys configured via env vars
|
||||
# (boolean only — never expose actual values)
|
||||
@@ -765,6 +878,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
"openrouter_premium": has_openrouter, # same key, different model
|
||||
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
|
||||
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
|
||||
"google_cloud": bool(os.getenv("GOOGLE_CLOUD_API_KEY", "").strip()),
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
@@ -812,6 +926,52 @@ async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
|
||||
status_code=200, content={"available": True, "test_result": result}
|
||||
)
|
||||
|
||||
elif provider == "google_cloud":
|
||||
api_key = _key(provider_config.api_key, "GOOGLE_CLOUD_API_KEY")
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"available": False,
|
||||
"error": "Aucune clé API Google Cloud trouvée (JSON ou .env GOOGLE_CLOUD_API_KEY).",
|
||||
},
|
||||
)
|
||||
import requests as _requests
|
||||
|
||||
resp = _requests.post(
|
||||
"https://translation.googleapis.com/language/translate/v2",
|
||||
params={"key": api_key},
|
||||
json={"q": "bonjour", "target": "en", "format": "text"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
translated = (
|
||||
resp.json()
|
||||
.get("data", {})
|
||||
.get("translations", [{}])[0]
|
||||
.get("translatedText", "")
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"available": True, "test_result": translated},
|
||||
)
|
||||
elif resp.status_code in (401, 403):
|
||||
return JSONResponse(
|
||||
status_code=resp.status_code,
|
||||
content={
|
||||
"available": False,
|
||||
"error": f"Clé API invalide ou API non activée dans Google Cloud Console (HTTP {resp.status_code}).",
|
||||
},
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"available": False,
|
||||
"error": f"Erreur Google Cloud Translation HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
},
|
||||
)
|
||||
|
||||
elif provider == "deepl":
|
||||
api_key = _key(provider_config.api_key, "DEEPL_API_KEY")
|
||||
if not api_key:
|
||||
@@ -975,3 +1135,284 @@ async def list_ollama_models(admin_id: str = Depends(require_admin)):
|
||||
status_code=500,
|
||||
content={"error": "INTERNAL_ERROR", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PRICING MANAGEMENT (Admin only)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class PlanPricingUpdate(BaseModel):
|
||||
price_monthly: Optional[float] = None
|
||||
price_yearly: Optional[float] = None # ignoré : calculé serveur (cohérence −20 % annuel)
|
||||
stripe_price_id_monthly: Optional[str] = None
|
||||
stripe_price_id_yearly: Optional[str] = None
|
||||
|
||||
|
||||
class PricingConfig(BaseModel):
|
||||
starter: PlanPricingUpdate = PlanPricingUpdate()
|
||||
pro: PlanPricingUpdate = PlanPricingUpdate()
|
||||
business: PlanPricingUpdate = PlanPricingUpdate()
|
||||
stripe_secret_key: Optional[str] = None
|
||||
stripe_publishable_key: Optional[str] = None
|
||||
stripe_webhook_secret: Optional[str] = None
|
||||
|
||||
|
||||
def _update_env_file(updates: dict):
|
||||
"""Persist key=value pairs into .env file (in-place)."""
|
||||
env_path = config.BASE_DIR / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
lines = env_path.read_text().splitlines()
|
||||
updated_keys = set()
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if "=" in stripped and not stripped.startswith("#"):
|
||||
key = stripped.split("=", 1)[0]
|
||||
if key in updates:
|
||||
new_lines.append(f"{key}={updates[key]}")
|
||||
updated_keys.add(key)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
# Append any keys not already in .env
|
||||
for key, val in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f"{key}={val}")
|
||||
env_path.write_text("\n".join(new_lines))
|
||||
|
||||
|
||||
@router.get("/pricing")
|
||||
async def get_pricing_config(admin_id: str = Depends(require_admin)):
|
||||
"""Return current pricing config (plans + Stripe IDs)."""
|
||||
result = {}
|
||||
for plan_id in ("starter", "pro", "business"):
|
||||
try:
|
||||
pt = PlanType(plan_id)
|
||||
plan = PLANS[pt]
|
||||
except Exception:
|
||||
continue
|
||||
monthly, yearly = pricing_cfg.get_effective_monthly_yearly(plan_id)
|
||||
sm, sy = pricing_cfg.stripe_price_ids_for_plan(plan_id)
|
||||
result[plan_id] = {
|
||||
"name": plan["name"],
|
||||
"price_monthly": monthly,
|
||||
"price_yearly": yearly,
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
"stripe_price_id_monthly": sm,
|
||||
"stripe_price_id_yearly": sy,
|
||||
}
|
||||
|
||||
stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY", "").strip().startswith("sk_"))
|
||||
return JSONResponse(status_code=200, content={
|
||||
"data": result,
|
||||
"stripe": {
|
||||
"configured": stripe_configured,
|
||||
"has_secret_key": bool(os.getenv("STRIPE_SECRET_KEY", "").strip()),
|
||||
"has_publishable_key": bool(os.getenv("STRIPE_PUBLISHABLE_KEY", "").strip()),
|
||||
"has_webhook_secret": bool(os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()),
|
||||
"publishable_key": os.getenv("STRIPE_PUBLISHABLE_KEY", ""),
|
||||
},
|
||||
"meta": {
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
"yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.put("/pricing")
|
||||
async def update_pricing_config(
|
||||
config_update: PricingConfig,
|
||||
admin_id: str = Depends(require_admin),
|
||||
):
|
||||
"""Update pricing (prices + Stripe IDs). Persists to JSON and .env."""
|
||||
try:
|
||||
overrides = pricing_cfg.load_pricing_overrides()
|
||||
env_updates = {}
|
||||
|
||||
for plan_id in ("starter", "pro", "business"):
|
||||
plan_update = getattr(config_update, plan_id)
|
||||
ov = dict(overrides.get(plan_id, {}))
|
||||
|
||||
if plan_update.price_monthly is not None:
|
||||
pricing_cfg.validate_monthly_price_eur(plan_update.price_monthly)
|
||||
ov["price_monthly"] = float(plan_update.price_monthly)
|
||||
ov = pricing_cfg.normalize_plan_override_block(plan_id, ov)
|
||||
|
||||
if plan_update.stripe_price_id_monthly is not None:
|
||||
mid = pricing_cfg.validate_stripe_price_id(
|
||||
plan_update.stripe_price_id_monthly,
|
||||
f"{plan_id} stripe_price_id_monthly",
|
||||
)
|
||||
ov["stripe_price_id_monthly"] = mid
|
||||
if mid:
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_MONTHLY"] = mid
|
||||
|
||||
if plan_update.stripe_price_id_yearly is not None:
|
||||
yid = pricing_cfg.validate_stripe_price_id(
|
||||
plan_update.stripe_price_id_yearly,
|
||||
f"{plan_id} stripe_price_id_yearly",
|
||||
)
|
||||
ov["stripe_price_id_yearly"] = yid
|
||||
if yid:
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_YEARLY"] = yid
|
||||
|
||||
overrides[plan_id] = ov
|
||||
|
||||
if config_update.stripe_secret_key:
|
||||
pricing_cfg.validate_stripe_secret_key(config_update.stripe_secret_key)
|
||||
env_updates["STRIPE_SECRET_KEY"] = config_update.stripe_secret_key.strip()
|
||||
try:
|
||||
import stripe as _stripe
|
||||
|
||||
_stripe.api_key = env_updates["STRIPE_SECRET_KEY"]
|
||||
except Exception:
|
||||
pass
|
||||
if config_update.stripe_publishable_key:
|
||||
pricing_cfg.validate_stripe_publishable_key(
|
||||
config_update.stripe_publishable_key
|
||||
)
|
||||
env_updates["STRIPE_PUBLISHABLE_KEY"] = (
|
||||
config_update.stripe_publishable_key.strip()
|
||||
)
|
||||
if config_update.stripe_webhook_secret:
|
||||
pricing_cfg.validate_stripe_webhook_secret(
|
||||
config_update.stripe_webhook_secret
|
||||
)
|
||||
env_updates["STRIPE_WEBHOOK_SECRET"] = (
|
||||
config_update.stripe_webhook_secret.strip()
|
||||
)
|
||||
|
||||
for plan_id in ("starter", "pro", "business"):
|
||||
overrides[plan_id] = pricing_cfg.normalize_plan_override_block(
|
||||
plan_id, overrides.get(plan_id, {})
|
||||
)
|
||||
|
||||
try:
|
||||
pricing_cfg.save_pricing_overrides(overrides)
|
||||
except OSError as e:
|
||||
logger.exception("admin_pricing_save_failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Impossible d'écrire data/pricing_overrides.json (droits disque ou chemin). "
|
||||
f"Détail : {e}"
|
||||
),
|
||||
) from e
|
||||
if env_updates:
|
||||
_update_env_file(env_updates)
|
||||
pricing_cfg.apply_runtime_config_after_admin_write()
|
||||
|
||||
logger.info("admin_pricing_updated by %s keys=%s", admin_id, list(env_updates.keys()))
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": overrides,
|
||||
"meta": {
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
},
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/pricing/setup-stripe")
|
||||
async def setup_stripe_products(admin_id: str = Depends(require_admin)):
|
||||
"""Auto-create Stripe products & prices from backend, then save IDs to .env."""
|
||||
secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip()
|
||||
if not secret_key or not secret_key.startswith("sk_"):
|
||||
return JSONResponse(status_code=400, content={
|
||||
"error": "STRIPE_NOT_CONFIGURED",
|
||||
"message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.",
|
||||
})
|
||||
|
||||
try:
|
||||
import stripe as _stripe
|
||||
_stripe.api_key = secret_key
|
||||
except ImportError:
|
||||
return JSONResponse(status_code=500, content={
|
||||
"error": "STRIPE_NOT_INSTALLED",
|
||||
"message": "Le module stripe n'est pas installé sur le serveur.",
|
||||
})
|
||||
|
||||
plans_meta = [
|
||||
("starter", "Office Translator Starter"),
|
||||
("pro", "Office Translator Pro"),
|
||||
("business", "Office Translator Business"),
|
||||
]
|
||||
|
||||
results = {}
|
||||
env_updates = {}
|
||||
errors = []
|
||||
|
||||
for plan_id, product_name in plans_meta:
|
||||
monthly, yearly = pricing_cfg.get_effective_monthly_yearly(plan_id)
|
||||
monthly_cents = int(round(monthly * 100))
|
||||
yearly_cents = int(round(yearly * 100))
|
||||
try:
|
||||
# Find or create product
|
||||
existing = _stripe.Product.search(query=f"name:\"{product_name}\"")
|
||||
if existing.data:
|
||||
product = existing.data[0]
|
||||
else:
|
||||
product = _stripe.Product.create(
|
||||
name=product_name,
|
||||
description=f"Abonnement Office Translator — Forfait {plan_id.capitalize()}",
|
||||
metadata={"plan": plan_id},
|
||||
)
|
||||
|
||||
# Find or create monthly price
|
||||
prices = _stripe.Price.list(product=product.id, active=True, limit=20)
|
||||
monthly_price = next((
|
||||
p for p in prices.data
|
||||
if p.recurring and p.recurring.interval == "month"
|
||||
and p.unit_amount == monthly_cents and p.currency == "eur"
|
||||
), None)
|
||||
yearly_price = next((
|
||||
p for p in prices.data
|
||||
if p.recurring and p.recurring.interval == "year"
|
||||
and p.unit_amount == yearly_cents and p.currency == "eur"
|
||||
), None)
|
||||
|
||||
if not monthly_price:
|
||||
monthly_price = _stripe.Price.create(
|
||||
product=product.id, unit_amount=monthly_cents, currency="eur",
|
||||
recurring={"interval": "month"}, metadata={"plan": plan_id},
|
||||
)
|
||||
if not yearly_price:
|
||||
yearly_price = _stripe.Price.create(
|
||||
product=product.id, unit_amount=yearly_cents, currency="eur",
|
||||
recurring={"interval": "year"}, metadata={"plan": plan_id},
|
||||
)
|
||||
|
||||
results[plan_id] = {
|
||||
"product_id": product.id,
|
||||
"monthly_price_id": monthly_price.id,
|
||||
"yearly_price_id": yearly_price.id,
|
||||
"created": True,
|
||||
}
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_MONTHLY"] = monthly_price.id
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_YEARLY"] = yearly_price.id
|
||||
|
||||
except Exception as e:
|
||||
errors.append({"plan": plan_id, "error": str(e)})
|
||||
|
||||
if env_updates:
|
||||
_update_env_file(env_updates)
|
||||
overrides = pricing_cfg.load_pricing_overrides()
|
||||
for plan_id, ids in results.items():
|
||||
ov = overrides.get(plan_id, {})
|
||||
ov["stripe_price_id_monthly"] = ids["monthly_price_id"]
|
||||
ov["stripe_price_id_yearly"] = ids["yearly_price_id"]
|
||||
overrides[plan_id] = ov
|
||||
pricing_cfg.save_pricing_overrides(overrides)
|
||||
pricing_cfg.apply_runtime_config_after_admin_write()
|
||||
|
||||
logger.info(f"admin_stripe_setup by {admin_id}: {list(results.keys())}")
|
||||
return JSONResponse(status_code=200, content={
|
||||
"data": results,
|
||||
"errors": errors,
|
||||
"env_updated": list(env_updates.keys()),
|
||||
"meta": {},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user