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

@@ -72,6 +72,9 @@ class GoogleAuthRequest(BaseModel):
class CheckoutRequest(BaseModel):
plan: PlanType
billing_period: str = "monthly"
# Code promo optionnel — validé localement AVANT toute création de session
# Stripe (actif, forfait, expiration, plafond).
promo_code: Optional[str] = None
class CreditsCheckoutRequest(BaseModel):
@@ -844,13 +847,19 @@ async def create_checkout_v1(request: CheckoutRequest, user=Depends(require_user
user_id=user.id,
plan=request.plan,
billing_period=request.billing_period,
promo_code=(request.promo_code or "").strip(),
)
if "error" in result and not result.get("demo_mode"):
return JSONResponse(
status_code=400,
content={
"error": "CHECKOUT_FAILED",
"error": result.get("error_code") or "CHECKOUT_FAILED",
"message": result["error"],
**(
{"promo_reason": result["promo_reason"]}
if result.get("promo_reason")
else {}
),
},
)
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
@@ -934,6 +943,56 @@ async def get_billing_portal_v1(user=Depends(require_user)):
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
# ============== Codes promo (validation publique avant paiement) ==============
promos_public_router = APIRouter(prefix="/api/v1/promos", tags=["Promotions"])
@promos_public_router.get(
"/validate",
summary="Valider un code promo",
description=(
"Vérifie un code promo pour un forfait donné AVANT l'ouverture du "
"paiement : renvoie valid, reason_code (cause explicite) et le montant "
"de la remise. Aucune authentification requise."
),
)
async def validate_promo_v1(
code: str = Query(..., min_length=1, max_length=24),
plan: str = Query("pro", min_length=1, max_length=32),
):
from services.payment_service import PromoValidationError, validate_promo_code
wanted = (code or "").strip().upper()
plan_id = (plan or "").strip().lower()
try:
promo = validate_promo_code(wanted, plan_id)
except PromoValidationError as e:
return JSONResponse(
status_code=200,
content={
"data": {
"valid": False,
"reason_code": e.reason,
"message": e.message,
},
"meta": {},
},
)
discount: Dict[str, Any] = {}
if promo.get("type") == "percent":
discount["percent_off"] = promo.get("value")
else:
discount["amount_off"] = promo.get("value")
return JSONResponse(
status_code=200,
content={
"data": {"valid": True, "reason_code": None, **discount},
"meta": {},
},
)
# ============== Forgot / Reset password ==============