feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s

- Chaque modele d'un palier devient une route {canal, modele} : openrouter,
  deepseek direct, zhipu (z.ai, nouveau canal), minimax, openai, xai ;
  repli automatique sur la route suivante si la cle manque, compatibilite
  ascendante avec les anciens reglages a chaines
- Page Fournisseurs refondue en sections ; interrupteurs et chaine de
  secours retablis ; tests de connexion avec delai d'attente
- Emails de relance generes par IA (canal au choix, prompt construit depuis
  le plan marketing, HTML sane, RTL arabe/persan, jamais d'envoi automatique)
- Codes promo : validation locale avant Stripe, Coupon+PromotionCode avec
  double plafond, comptage par session (webhook+sync=1 fois), ecriture
  atomique, relier-a-Stripe, suppression, validation publique au checkout
- Page Tarifs : champ code promo (?promo=) avec verification traduite
- Tests : 1357 verts (facturation par palier testee dans le worker,
  point de contact providers/available, promos, generation)
This commit is contained in:
2026-09-05 19:46:33 +02:00
parent 6c26712687
commit 0a013c679d
47 changed files with 7210 additions and 679 deletions

View File

@@ -1,9 +1,13 @@
"""
Stripe payment integration for subscriptions and credits
"""
import json
import os
import logging
from typing import Optional, Dict, Any
import re
import threading
from pathlib import Path
from typing import Optional, Dict, List, Any, Tuple
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -21,6 +25,7 @@ from services.auth_service import get_user_by_id, update_user, add_credits
from services.pricing_config import (
get_subscription_line_amount_eur,
stripe_price_ids_for_plan,
stripe_mode,
)
@@ -50,24 +55,389 @@ def is_stripe_configured() -> bool:
return _ensure_stripe_client_configured()
# ═══════════════════════════════════════════════════════════════════════════
# Codes promo (réductions) — stockés dans data/promo_codes.json (comme
# pricing_overrides.json : JSON géré par l'admin, pas de table en base).
#
# Chaque entrée :
# code, active, type ("percent"|"amount"), value, plans (["all"] ou ids),
# max_redemptions (None = illimité), times_used, expires_at (ISO ou None),
# stripe_coupon_id / stripe_promotion_code_id (None si créé hors Stripe),
# stripe_mode ("test"|"live"|"local_only"), created_at, deactivated_at.
#
# Double plafond : le compteur local (times_used) est incrémenté uniquement à
# la complétion du paiement, ET Stripe applique son propre max_redemptions.
# ═══════════════════════════════════════════════════════════════════════════
try:
from config import config as _app_config
PROMO_CODES_FILE: Path = _app_config.BASE_DIR / "data" / "promo_codes.json"
except Exception: # pragma: no cover — config toujours disponible en pratique
PROMO_CODES_FILE = Path("data") / "promo_codes.json"
PROMO_CODE_RE = re.compile(r"^[A-Z0-9-]{3,24}$")
_promo_lock = threading.Lock()
class PromoValidationError(Exception):
"""Code promo inutilisable au checkout — la cause est explicite."""
def __init__(self, reason: str, message: str):
self.reason = reason # unknown|inactive|expired|exhausted|plan|local_only
self.message = message
super().__init__(message)
class DuplicatePromoError(Exception):
"""Un code identique existe déjà (vérification faite SOUS le verrou)."""
class StripePromoCreationError(Exception):
"""Stripe a refusé la création du Coupon/Promotion Code (rien n'est persisté)."""
def load_promo_codes() -> List[dict]:
try:
if PROMO_CODES_FILE.exists():
with open(PROMO_CODES_FILE, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception as e:
logger.warning("Lecture promo_codes impossible: %s", e)
return []
def save_promo_codes(entries: List[dict]) -> None:
"""Écriture atomique : fichier temporaire puis renommage (os.replace) —
un plantage pendant l'écriture ne peut pas laisser un JSON tronqué."""
PROMO_CODES_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_path = PROMO_CODES_FILE.with_name(PROMO_CODES_FILE.name + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, PROMO_CODES_FILE)
def _find_promo(entries: List[dict], code: str) -> Optional[dict]:
wanted = (code or "").strip().upper()
for e in entries:
if (e.get("code") or "").strip().upper() == wanted:
return e
return None
def validate_promo_code(
code: str, plan: str, entries: Optional[List[dict]] = None
) -> dict:
"""Valide un code promo AVANT tout appel Stripe — lève PromoValidationError.
Causes explicites : inconnu, désactivé, expiré, épuisé, forfait non couvert,
ou code créé « local_only » (sans Stripe, donc inapplicable au checkout).
Une limite ou un compteur illisible (JSON corrompu) est traité par prudence
comme un code épuisé, jamais comme illimité.
"""
wanted = (code or "").strip().upper()
if not wanted:
raise PromoValidationError("unknown", "Aucun code promo fourni.")
promo = _find_promo(entries if entries is not None else load_promo_codes(), wanted)
if not promo:
raise PromoValidationError("unknown", f"Le code promo « {wanted} » n'existe pas.")
if not promo.get("active", False):
raise PromoValidationError("inactive", f"Le code promo « {wanted} » n'est plus actif.")
expires_at = promo.get("expires_at")
if expires_at:
try:
exp = datetime.fromisoformat(str(expires_at))
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) >= exp:
raise PromoValidationError(
"expired", f"Le code promo « {wanted} » a expiré."
)
except PromoValidationError:
raise
except (TypeError, ValueError):
logger.warning("promo %s: expires_at illisible (%s)", wanted, expires_at)
max_redemptions = promo.get("max_redemptions")
if max_redemptions is not None:
try:
limit = int(max_redemptions)
used = int(promo.get("times_used", 0) or 0)
except (TypeError, ValueError):
raise PromoValidationError(
"exhausted",
f"Le code promo « {wanted} » est inutilisable : sa limite "
"d'utilisations est illisible.",
)
if used >= limit:
raise PromoValidationError(
"exhausted",
f"Le code promo « {wanted} » a atteint sa limite d'utilisations.",
)
plans = promo.get("plans") or ["all"]
if "all" not in plans and plan not in plans:
raise PromoValidationError(
"plan",
f"Le code promo « {wanted} » n'est pas valable pour le forfait « {plan} ».",
)
if not promo.get("stripe_promotion_code_id"):
raise PromoValidationError(
"local_only",
f"Le code promo « {wanted} » n'est pas relié à Stripe et ne peut pas "
"être appliqué au paiement.",
)
return promo
def increment_promo_usage(code: str, session_id: str = "") -> bool:
"""Incrémente le compteur local d'un code promo (à la complétion SEULEMENT).
``session_id`` (identifiant de session Stripe de checkout) protège du double
comptage : un webhook suivi d'une synchronisation manuelle du MÊME paiement
ne compte qu'une fois, y compris en mode JSON (sans base).
"""
wanted = (code or "").strip().upper()
if not wanted:
return False
sid = (session_id or "").strip()
with _promo_lock:
entries = load_promo_codes()
promo = _find_promo(entries, wanted)
if not promo:
return False
counted = promo.get("counted_sessions") or []
if sid and sid in counted:
logger.info(
"promo %s: checkout session %s déjà comptée — incrément ignoré",
wanted, sid,
)
return False
try:
promo["times_used"] = int(promo.get("times_used", 0) or 0) + 1
except (TypeError, ValueError):
promo["times_used"] = 1
promo["last_used_at"] = datetime.now(timezone.utc).isoformat()
if sid:
promo["counted_sessions"] = list(counted) + [sid]
save_promo_codes(entries)
logger.info("promo_code usage incremented: %s%s", wanted, promo["times_used"])
return True
def deactivate_promo_code(code: str) -> Optional[dict]:
"""Désactive un code localement ET chez Stripe (promotion code désactivée)."""
wanted = (code or "").strip().upper()
with _promo_lock:
entries = load_promo_codes()
promo = _find_promo(entries, wanted)
if not promo:
return None
promo["active"] = False
promo["deactivated_at"] = datetime.now(timezone.utc).isoformat()
if promo.get("stripe_promotion_code_id") and is_stripe_configured():
try:
stripe.PromotionCode.modify(promo["stripe_promotion_code_id"], active=False)
except Exception as e:
# Stripe peut être injoignable : la désactivation locale reste
# effective et fait foi pour le checkout.
logger.error("Stripe PromotionCode.deactivate failed for %s: %s", wanted, e)
save_promo_codes(entries)
return promo
def delete_promo_code(code: str) -> Optional[dict]:
"""Supprime un code promo (admin). La promotion code Stripe associée est
supprimée aussi (repli : désactivée si la suppression est refusée)."""
wanted = (code or "").strip().upper()
with _promo_lock:
entries = load_promo_codes()
promo = _find_promo(entries, wanted)
if not promo:
return None
if promo.get("stripe_promotion_code_id") and is_stripe_configured():
try:
stripe.PromotionCode.delete(promo["stripe_promotion_code_id"])
except Exception as e:
logger.warning(
"Stripe PromotionCode.delete failed for %s (%s) — fallback: deactivate",
wanted, e,
)
try:
stripe.PromotionCode.modify(promo["stripe_promotion_code_id"], active=False)
except Exception as e2:
logger.error("Stripe PromotionCode deactivate fallback failed: %s", e2)
entries = [e for e in entries if e is not promo]
save_promo_codes(entries)
logger.info("promo %s supprimé", wanted)
return promo
def _create_stripe_refs(
code: str, type: str, value: float,
max_redemptions: Optional[int], expires_at: Optional[str],
) -> tuple:
"""Crée le Coupon + Promotion Code Stripe. En cas d'échec Stripe, supprime
un coupon déjà créé (pas d'objet orphelin) et lève StripePromoCreationError."""
coupon = None
try:
coupon_params: Dict[str, Any] = {
"duration": "once",
"name": f"Reduction {code}",
}
if type == "percent":
coupon_params["percent_off"] = round(float(value), 2)
else:
coupon_params["amount_off"] = int(round(float(value) * 100))
coupon_params["currency"] = "eur"
coupon = stripe.Coupon.create(**coupon_params)
pc_params: Dict[str, Any] = {"coupon": coupon.id}
if max_redemptions:
pc_params["max_redemptions"] = int(max_redemptions)
if expires_at:
try:
exp = datetime.fromisoformat(str(expires_at))
pc_params["expires_at"] = int(
exp.replace(tzinfo=exp.tzinfo or timezone.utc).timestamp()
)
except (TypeError, ValueError):
logger.warning("promo %s: expires_at illisible, ignoré côté Stripe", code)
promo_code_obj = stripe.PromotionCode.create(**pc_params)
return coupon.id, promo_code_obj.id
except Exception as e:
stripe_err = getattr(stripe, "error", None) if stripe is not None else None
if stripe_err is not None and isinstance(e, stripe_err.StripeError):
if coupon:
try:
stripe.Coupon.delete(coupon.id)
logger.info("coupon orphelin %s supprimé après échec Stripe", coupon.id)
except Exception as del_err:
logger.error("suppression du coupon orphelin %s impossible: %s", coupon.id, del_err)
raise StripePromoCreationError(str(e)) from e
raise
def create_promo_code(
code: str,
type: str,
value: float,
plans: Optional[List[str]] = None,
max_redemptions: Optional[int] = None,
expires_at: Optional[str] = None,
) -> dict:
"""Crée un code promo localement et, si Stripe est configuré, le Coupon +
Promotion Code Stripe associés (double plafond local ET Stripe).
Sans Stripe, le code est créé en « local_only » : il reste visible dans
l'admin mais sera refusé au checkout avec un message clair.
Le doublon est vérifié SOUS le verrou : deux créations simultanées du même
code aboutissent à un seul enregistrement.
"""
wanted = (code or "").strip().upper()
now_iso = datetime.now(timezone.utc).isoformat()
with _promo_lock:
entries = load_promo_codes()
if _find_promo(entries, wanted):
raise DuplicatePromoError(
f"Le code promo « {wanted} » existe déjà."
)
entry: Dict[str, Any] = {
"code": wanted,
"active": True,
"type": type,
"value": float(value),
"plans": plans or ["all"],
"max_redemptions": int(max_redemptions) if max_redemptions else None,
"times_used": 0,
"counted_sessions": [],
"expires_at": expires_at or None,
"stripe_coupon_id": None,
"stripe_promotion_code_id": None,
"stripe_mode": "local_only",
"created_at": now_iso,
"deactivated_at": None,
}
if is_stripe_configured():
coupon_id, promo_code_id = _create_stripe_refs(
wanted, type, value, max_redemptions, expires_at
)
entry["stripe_coupon_id"] = coupon_id
entry["stripe_promotion_code_id"] = promo_code_id
entry["stripe_mode"] = stripe_mode() if stripe_mode() != "unknown" else "test"
logger.info("promo %s créé sur Stripe (coupon=%s)", wanted, coupon_id)
else:
logger.info("promo %s créé en local_only (Stripe non configuré)", wanted)
entries.append(entry)
save_promo_codes(entries)
return entry
def link_promo_to_stripe(code: str) -> Optional[dict]:
"""Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code
et met à jour l'enregistrement local. Renvoie None si le code est inconnu."""
wanted = (code or "").strip().upper()
with _promo_lock:
entries = load_promo_codes()
promo = _find_promo(entries, wanted)
if not promo:
return None
if not promo.get("active", False):
raise PromoValidationError(
"inactive", f"Le code promo « {wanted} » est désactivé : réactivez-le d'abord."
)
if promo.get("stripe_promotion_code_id"):
return promo # déjà relié — rien à faire
if not is_stripe_configured():
raise RuntimeError("Stripe n'est pas configuré (clé secrète absente).")
coupon_id, promo_code_id = _create_stripe_refs(
wanted, promo.get("type", "percent"), float(promo.get("value", 0)),
promo.get("max_redemptions"), promo.get("expires_at"),
)
promo["stripe_coupon_id"] = coupon_id
promo["stripe_promotion_code_id"] = promo_code_id
promo["stripe_mode"] = stripe_mode() if stripe_mode() != "unknown" else "test"
save_promo_codes(entries)
logger.info("promo %s relié à Stripe (coupon=%s)", wanted, coupon_id)
return promo
async def create_checkout_session(
user_id: str,
plan: PlanType,
billing_period: str = "monthly", # monthly or yearly
success_url: str = "",
cancel_url: str = "",
promo_code: str = "",
) -> Optional[Dict[str, Any]]:
"""Create a Stripe checkout session for subscription"""
if not is_stripe_configured():
return {"error": "Stripe not configured", "demo_mode": True}
user = get_user_by_id(user_id)
if not user:
return {"error": "User not found"}
plan_config = PLANS[plan]
plan_id = plan.value
# Réduction : validation locale AVANT Stripe (actif, forfait, expiration,
# plafond local). Un code invalide n'entraîne aucune session Stripe.
promo_entry: Optional[dict] = None
promo_wanted = (promo_code or "").strip().upper()
if promo_wanted:
try:
promo_entry = validate_promo_code(promo_wanted, plan_id)
except PromoValidationError as e:
return {"error": e.message, "error_code": "PROMO_INVALID", "promo_reason": e.reason}
mid, yid = stripe_price_ids_for_plan(plan_id)
price_id = mid if billing_period == "monthly" else yid
@@ -112,22 +482,38 @@ async def create_checkout_session(
update_user(user_id, {"stripe_customer_id": customer_id})
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
mode="subscription",
payment_method_types=["card"],
line_items=[line_item],
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
metadata={"user_id": user_id, "plan": plan.value},
subscription_data={
"metadata": {"user_id": user_id, "plan": plan.value}
}
)
session_kwargs: Dict[str, Any] = {
"customer": customer_id,
"mode": "subscription",
"payment_method_types": ["card"],
"line_items": [line_item],
"success_url": success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
"cancel_url": cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
"metadata": {
"user_id": user_id,
"plan": plan.value,
**({"promo_code": promo_wanted} if promo_entry else {}),
},
"subscription_data": {
"metadata": {
"user_id": user_id,
"plan": plan.value,
**({"promo_code": promo_wanted} if promo_entry else {}),
}
},
}
if promo_entry:
# Remise Stripe : la Promotion Code créée par l'admin porte les
# plafonds Stripe (max_redemptions / expires_at).
session_kwargs["discounts"] = [
{"promotion_code": promo_entry["stripe_promotion_code_id"]}
]
session = stripe.checkout.Session.create(**session_kwargs)
return {
"session_id": session.id,
"url": session.url
"url": session.url,
**({"promo_code": promo_wanted} if promo_entry else {}),
}
except Exception as e:
return {"error": str(e)}
@@ -415,6 +801,17 @@ async def handle_checkout_completed(session: Dict):
})
logger.info("Checkout synced: user %s → plan=%s tier=%s sub=%s", user_id, plan, tier, subscription_id)
# Réduction : le compteur local du code promo n'augmente qu'à la
# complétion réelle du paiement (webhook OU synchronisation manuelle),
# via la métadonnée posée à la création de la session. Les crédits ne
# supportent pas de code promo.
promo_used = (metadata.get("promo_code") or "").strip()
if promo_used:
try:
increment_promo_usage(promo_used, session_id=session_id or "")
except Exception as promo_err:
logger.error("promo usage increment failed for %s: %s", promo_used, promo_err)
# Log to PaymentHistory
if db_available and session_id:
try: