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:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

200
services/pricing_config.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Centralisation des tarifs d'abonnement (source unique pour admin, API publique, Stripe, checkout).
Règle métier : le prix annuel est toujours dérivé du mensuel :
prix_annuel = arrondi(mensuel × 12 × YEARLY_DISCOUNT_FACTOR, 2 décimales)
avec YEARLY_DISCOUNT_FACTOR = 0,8 (20 % sur l'équivalent 12 mois au mensuel).
Les overrides admin ne peuvent pas fixer un annuel incohérent : le serveur recalcule et persiste.
"""
from __future__ import annotations
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Optional
from models.subscription import PLANS, PlanType
logger = logging.getLogger(__name__)
# Toujours relatif à la racine du projet (config.py), pas au cwd du processus —
# sinon uvicorn lancé depuis un autre dossier écrit/lit un autre fichier et les
# changements admin (ex. Starter 7 €) semblent « ne rien faire ».
from config import config as _app_config
PRICING_FILE = _app_config.BASE_DIR / "data" / "pricing_overrides.json"
# 20 % sur la facturation annuelle vs 12 × prix mensuel
YEARLY_DISCOUNT_FACTOR = 0.8
ANNUAL_DISCOUNT_PERCENT = 20
MAX_MONTHLY_PRICE_EUR = 50_000.0
MIN_MONTHLY_PRICE_EUR = 0.01
_STRIPE_PRICE_ID_RE = re.compile(r"^price_[a-zA-Z0-9]+$")
_STRIPE_SECRET_RE = re.compile(r"^sk_(test|live)_[a-zA-Z0-9]+$")
_STRIPE_PUBLISHABLE_RE = re.compile(r"^pk_(test|live)_[a-zA-Z0-9]+$")
_STRIPE_WEBHOOK_SECRET_RE = re.compile(r"^whsec_[a-zA-Z0-9]+$")
def compute_yearly_from_monthly(monthly: float) -> float:
return round(float(monthly) * 12.0 * YEARLY_DISCOUNT_FACTOR, 2)
def validate_monthly_price_eur(value: float) -> None:
v = float(value)
if v < MIN_MONTHLY_PRICE_EUR or v > MAX_MONTHLY_PRICE_EUR:
raise ValueError(
f"Le prix mensuel doit être entre {MIN_MONTHLY_PRICE_EUR} et {MAX_MONTHLY_PRICE_EUR}"
)
def validate_stripe_price_id(value: Optional[str], field_label: str) -> str:
"""Retourne une chaîne normalisée ou lève ValueError si format douteux."""
if value is None:
return ""
s = str(value).strip()
if not s:
return ""
lower = s.lower()
if "xxx" in lower or "placeholder" in lower:
raise ValueError(f"{field_label} : identifiant invalide (placeholder interdit).")
if not _STRIPE_PRICE_ID_RE.match(s):
raise ValueError(
f"{field_label} : format attendu price_… (identifiant Stripe Price)."
)
return s
def validate_stripe_secret_key(value: str) -> None:
s = value.strip()
if not _STRIPE_SECRET_RE.match(s):
raise ValueError(
"Clé secrète Stripe invalide (attendu sk_test_… ou sk_live_…)."
)
def validate_stripe_publishable_key(value: str) -> None:
s = value.strip()
if not _STRIPE_PUBLISHABLE_RE.match(s):
raise ValueError(
"Clé publique Stripe invalide (attendu pk_test_… ou pk_live_…)."
)
def validate_stripe_webhook_secret(value: str) -> None:
s = value.strip()
if not _STRIPE_WEBHOOK_SECRET_RE.match(s):
raise ValueError("Secret webhook Stripe invalide (attendu whsec_…).")
def load_pricing_overrides() -> dict[str, Any]:
try:
if PRICING_FILE.exists():
return json.loads(PRICING_FILE.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("Lecture pricing_overrides impossible: %s", e)
return {}
def save_pricing_overrides(data: dict[str, Any]) -> None:
PRICING_FILE.parent.mkdir(parents=True, exist_ok=True)
PRICING_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.info("pricing_overrides enregistré : %s", PRICING_FILE.resolve())
def reload_dotenv_from_dotenv_file() -> None:
"""
Recharge le fichier .env dans os.environ (override=True).
Nécessaire après mise à jour admin : sans cela le process garde les valeurs
du démarrage et il faudrait redémarrer le serveur.
"""
try:
from dotenv import load_dotenv
p = _app_config.BASE_DIR / ".env"
if p.exists():
load_dotenv(p, override=True)
except Exception as e:
logger.debug("reload_dotenv_from_dotenv_file: %s", e)
def apply_runtime_config_after_admin_write() -> None:
"""À appeler après sauvegarde admin (JSON +/ou .env)."""
reload_dotenv_from_dotenv_file()
def stripe_price_id_for_plan(
plan_id: str,
period: str,
overrides: Optional[dict] = None,
) -> str:
"""period: 'monthly' | 'yearly' — aligné sur STRIPE_PRICE_STARTER_MONTHLY etc."""
ov = (overrides if overrides is not None else load_pricing_overrides()).get(
plan_id, {}
)
key = f"stripe_price_id_{period}"
raw = (ov.get(key) or "").strip()
if raw:
return raw
# Fichier .env modifié par l'admin : recharger depuis le disque (pas le os.environ figé au démarrage).
reload_dotenv_from_dotenv_file()
env_key = f"STRIPE_PRICE_{plan_id.upper()}_{period.upper()}"
env_val = (os.getenv(env_key, "") or "").strip()
return env_val
def stripe_price_ids_for_plan(plan_id: str) -> tuple[str, str]:
ov = load_pricing_overrides()
return (
stripe_price_id_for_plan(plan_id, "monthly", ov),
stripe_price_id_for_plan(plan_id, "yearly", ov),
)
def get_effective_monthly_yearly(plan_id: str) -> tuple[float, float]:
"""Mensuel depuis override ou PLANS ; annuel dérivé sauf forfaits spéciaux (gratuit / entreprise)."""
try:
pt = PlanType(plan_id)
except ValueError:
return (0.0, 0.0)
base = PLANS[pt]
ov = load_pricing_overrides().get(plan_id, {})
monthly = float(ov.get("price_monthly", base["price_monthly"]))
if monthly < 0:
return (float(base["price_monthly"]), float(base["price_yearly"]))
if monthly == 0.0:
return (0.0, 0.0)
yearly = compute_yearly_from_monthly(monthly)
return (monthly, yearly)
def get_subscription_line_amount_eur(plan: PlanType, billing_period: str) -> float:
"""Montant unitaire pour une période de facturation (checkout fallback price_data)."""
plan_id = plan.value
monthly, yearly = get_effective_monthly_yearly(plan_id)
if monthly < 0:
return -1.0
if monthly == 0.0:
return 0.0
if billing_period == "yearly":
return yearly
return monthly
def normalize_plan_override_block(plan_id: str, ov: dict[str, Any]) -> dict[str, Any]:
"""Recalcule price_yearly dans un bloc override à partir de price_monthly."""
try:
pt = PlanType(plan_id)
except ValueError:
return ov
base = PLANS[pt]
monthly = float(ov.get("price_monthly", base["price_monthly"]))
ov = dict(ov)
ov["price_monthly"] = monthly
ov["price_yearly"] = compute_yearly_from_monthly(monthly)
return ov