feat(abonnements): paliers LLM Essentielle/Premium, admin modeles par forfait, statistiques revenus, emails marketing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s

- Gamme officielle : Essentielle (Pro) = deepseek-v4-flash, glm-5.3-flash,
  minimax-m3 ; Premium (Business) = claude-sonnet-5, deepseek-v4-pro, glm-5.3
- Routage reel : le modele employe suit le palier IA du forfait (reglages
  admin > defaut du plan), garde anti-croisement de palier
- Nouvelle page admin « Modeles & abonnements » (matrice forfaits/paliers,
  modele par defaut, catalogue OpenRouter)
- Statistiques enrichies : revenus (30 j + total), MRR estime, credits,
  liste d'attente, paliers utilises
- Nouvelle page admin « Marketing » : audiences avec compteurs, apercu,
  envoi test obligatoire, historique, desabonnement public + en-tetes
  List-Unsubscribe ; .gitignore pour les donnees d'execution
- Interface (accueil + tarifs) alignee sur la gamme, 13 langues completes
- Tests : routage par palier (fonction + tache), marketing, revenus en base
This commit is contained in:
2026-09-05 12:51:49 +02:00
parent 4e6850d6b1
commit 6c26712687
76 changed files with 5289 additions and 271 deletions

View File

@@ -182,6 +182,89 @@ def _allowed_providers_for_plan(plan: PlanType) -> set:
return set(plan_cfg.get("providers", []))
def _plan_from_str(plan_str: Optional[str]) -> PlanType:
"""Resolve a ``user_plan`` string ("PlanType.PRO" or "pro") to a PlanType."""
name = (plan_str or "").strip().lower()
if name.startswith("plantype."):
name = name.split(".", 1)[1]
try:
return PlanType(name)
except ValueError:
if name:
logger.warning(
"Unrecognized user_plan %r — falling back to the Free plan "
"(Essentielle tier).",
plan_str,
)
return PlanType.FREE
def _plan_ai_models(plan: PlanType, premium: bool) -> list:
"""Official model range for the plan's AI tier (Essentielle or Premium).
Plans without their own list (Free, Starter, Enterprise custom) fall back
to the global defaults: Pro's Essentielle range and Business's Premium range.
"""
from models.subscription import (
DEFAULT_AI_MODELS_ESSENTIAL,
DEFAULT_AI_MODELS_PREMIUM,
)
key = "ai_models_premium" if premium else "ai_models_essential"
models = [m for m in (PLANS.get(plan, {}).get(key) or []) if m]
if not models:
models = list(DEFAULT_AI_MODELS_PREMIUM if premium else DEFAULT_AI_MODELS_ESSENTIAL)
return models
def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> str:
"""Modèle réellement routé pour le palier IA du plan.
Ordre de résolution (spec « Routage réel par abonnement ») :
1. réglages admin ``ai_tiers.<palier>.default_model`` (changement sans redéploiement) ;
2. premier modèle de ``ai_tiers.<palier>.models`` (ordre = priorité de secours) ;
3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``).
Garde d'exécution : un modèle résolu dans la gamme de l'autre palier est
rejeté et remplacé par le défaut du plan — un utilisateur Pro (moteur
openrouter = Essentielle) ne peut pas déclencher un modèle Premium.
"""
from models.subscription import (
DEFAULT_AI_MODELS_ESSENTIAL,
DEFAULT_AI_MODELS_PREMIUM,
)
tier_cfg = getattr(getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None)
resolved = ""
if tier_cfg is not None:
default = (getattr(tier_cfg, "default_model", "") or "").strip()
if default:
resolved = default
else:
tier_models = [m.strip() for m in (getattr(tier_cfg, "models", None) or []) if m and m.strip()]
if tier_models:
resolved = tier_models[0]
if not resolved:
resolved = _plan_ai_models(_plan_from_str(user_plan), premium)[0]
# Garde : le modèle résolu doit appartenir au palier demandé.
if premium and resolved in DEFAULT_AI_MODELS_ESSENTIAL:
logger.warning(
"ai_tiers.premium resolved to an Essentielle model (%s) — using the "
"plan Premium default instead.",
resolved,
)
resolved = _plan_ai_models(_plan_from_str(user_plan), premium=True)[0]
elif not premium and resolved in DEFAULT_AI_MODELS_PREMIUM:
logger.warning(
"ai_tiers.essential resolved to a Premium model (%s) — refusing: a "
"Pro subscription must never trigger a Premium model. Using the "
"plan Essentielle default instead.",
resolved,
)
resolved = _plan_ai_models(_plan_from_str(user_plan), premium=False)[0]
return resolved
def _image_translation_allowed_for_plan(plan: PlanType) -> bool:
"""Vision (text-in-images) is sold on Pro and above."""
return plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
@@ -1275,9 +1358,9 @@ async def _run_translation_job(
return (admin_val or "").strip() or os.getenv(env_var, default)
api_key = _cfg(_admin_cfg.openrouter.api_key, "OPENROUTER_API_KEY")
model = _cfg(_admin_cfg.openrouter.model, "OPENROUTER_MODEL", "google/gemini-3.5-flash")
if model in ("deepseek/deepseek-v3.2", "google/gemini-2.0-flash-001"):
model = "google/gemini-3.5-flash"
# Routage réel par abonnement : le modèle vient du palier IA du plan
# (réglages admin « ai_tiers » > défaut du plan) — voir _resolve_tier_model.
model = _resolve_tier_model(_admin_cfg, user_plan, premium=False)
# Story 3.10: Retrieve and format glossary terms for LLM prompt
glossary_terms = None
@@ -1363,7 +1446,8 @@ async def _run_translation_job(
)
elif _p == "openrouter_premium":
premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY")
premium_model = _cfg(_admin_cfg.openrouter_premium.model, "OPENROUTER_PREMIUM_MODEL", "anthropic/claude-sonnet-4.6")
# Palier Premium : résolu par abonnement (défaut anthropic/claude-sonnet-5).
premium_model = _resolve_tier_model(_admin_cfg, user_plan, premium=True)
if not premium_key:
premium_key = api_key # fall back to main openrouter key
if premium_key: