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
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user