Files
office_translator/services/pricing_config.py
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

201 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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