Files
office_translator/services/payment_service.py
sepehr 0a013c679d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
- 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)
2026-09-05 19:46:33 +02:00

1220 lines
47 KiB
Python

"""
Stripe payment integration for subscriptions and credits
"""
import json
import os
import logging
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__)
# Try to import stripe
try:
import stripe
STRIPE_AVAILABLE = True
except ImportError:
STRIPE_AVAILABLE = False
stripe = None
from models.subscription import PlanType, PLANS, CREDIT_PACKAGES, SubscriptionStatus
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,
)
# Stripe configuration
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
def _get_stripe_secret_key() -> str:
"""Read Stripe secret key at runtime to support hot-reload/admin updates."""
return os.getenv("STRIPE_SECRET_KEY", "").strip()
def _ensure_stripe_client_configured() -> bool:
"""Configure Stripe SDK lazily with the latest key from environment."""
if not STRIPE_AVAILABLE:
return False
secret = _get_stripe_secret_key()
if not secret:
return False
stripe.api_key = secret
return True
def is_stripe_configured() -> bool:
"""Check if Stripe is properly configured"""
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
# If no Stripe price id is configured, fall back to inline price_data.
# This makes test-mode checkout work with only STRIPE_SECRET_KEY configured.
line_item: Dict[str, Any]
if price_id and price_id not in ("price_xxx", ""):
line_item = {"price": price_id, "quantity": 1}
else:
amount = get_subscription_line_amount_eur(plan, billing_period)
if amount in (None, "", -1) or float(amount) <= 0:
return {
"error": "Impossible de créer le checkout: tarif invalide pour ce forfait."
}
amount_cents = int(round(float(amount) * 100))
interval = "year" if billing_period == "yearly" else "month"
line_item = {
"price_data": {
"currency": "eur",
"unit_amount": amount_cents,
"recurring": {"interval": interval},
"product_data": {
"name": f"Office Translator - {plan_config.get('name', plan_id)}"
},
},
"quantity": 1,
}
try:
# Create or get Stripe customer
if user.stripe_customer_id:
customer_id = user.stripe_customer_id
else:
customer = stripe.Customer.create(
email=user.email,
name=user.name,
metadata={"user_id": user_id}
)
customer_id = customer.id
update_user(user_id, {"stripe_customer_id": customer_id})
# Create checkout session
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,
**({"promo_code": promo_wanted} if promo_entry else {}),
}
except Exception as e:
return {"error": str(e)}
async def sync_checkout_session(
user_id: str,
session_id: str,
) -> Dict[str, Any]:
"""
Sync a completed Stripe Checkout session to local user state.
Useful in local/dev environments where webhooks may not reach the backend.
"""
if not is_stripe_configured():
return {"error": "Stripe not configured"}
user = get_user_by_id(user_id)
if not user:
return {"error": "User not found"}
if not session_id or not session_id.startswith("cs_"):
return {"error": "Invalid checkout session id"}
try:
session = stripe.checkout.Session.retrieve(session_id, expand=["subscription"])
except Exception as e:
return {"error": f"Unable to retrieve checkout session: {str(e)}"}
metadata = session.get("metadata", {}) or {}
session_user_id = metadata.get("user_id")
session_customer_id = session.get("customer")
# Security check: session must belong to current authenticated user.
if session_user_id and session_user_id != user_id:
return {"error": "Checkout session does not belong to current user"}
if user.stripe_customer_id and session_customer_id and session_customer_id != user.stripe_customer_id:
return {"error": "Checkout customer mismatch"}
if session.get("status") != "complete":
return {"error": "Checkout session is not completed yet"}
await handle_checkout_completed(session)
return {
"status": "synced",
"plan": metadata.get("plan"),
"session_id": session_id,
}
async def create_credits_checkout(
user_id: str,
package_index: int,
success_url: str = "",
cancel_url: str = "",
) -> Optional[Dict[str, Any]]:
"""Create a Stripe checkout session for credit purchase"""
if not is_stripe_configured():
return {"error": "Stripe not configured", "demo_mode": True}
if package_index < 0 or package_index >= len(CREDIT_PACKAGES):
return {"error": "Invalid package"}
user = get_user_by_id(user_id)
if not user:
return {"error": "User not found"}
package = CREDIT_PACKAGES[package_index]
try:
# Create or get Stripe customer
if user.stripe_customer_id:
customer_id = user.stripe_customer_id
else:
customer = stripe.Customer.create(
email=user.email,
name=user.name,
metadata={"user_id": user_id}
)
customer_id = customer.id
update_user(user_id, {"stripe_customer_id": customer_id})
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
mode="payment",
payment_method_types=["card"],
line_items=[{"price": package["stripe_price_id"], "quantity": 1}],
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?credits=purchased",
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
metadata={
"user_id": user_id,
"credits": package["credits"],
"type": "credits"
}
)
return {
"session_id": session.id,
"url": session.url
}
except Exception as e:
return {"error": str(e)}
async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
"""Handle Stripe webhook events"""
if not is_stripe_configured():
return {"error": "Stripe not configured"}
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError:
return {"error": "Invalid payload"}
except stripe.error.SignatureVerificationError:
return {"error": "Invalid signature"}
# Handle the event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
await handle_checkout_completed(session)
elif event["type"] == "customer.subscription.updated":
subscription = event["data"]["object"]
await handle_subscription_updated(subscription)
elif event["type"] == "customer.subscription.deleted":
subscription = event["data"]["object"]
await handle_subscription_deleted(subscription)
elif event["type"] == "invoice.payment_failed":
invoice = event["data"]["object"]
await handle_payment_failed(invoice)
elif event["type"] == "invoice.paid":
invoice = event["data"]["object"]
await handle_invoice_paid(invoice)
return {"status": "success"}
async def handle_checkout_completed(session: Dict):
"""Handle successful checkout"""
metadata = session.get("metadata", {}) or {}
user_id = metadata.get("user_id")
if not user_id:
return
session_id = session.get("id")
# Check for duplicate session processing using PaymentHistory.
# Use the Stripe payment_intent (one-time) or subscription id (recurring) as the
# idempotency key, NOT the checkout session id — Stripe can redeliver the event.
db_available = False
try:
from database.connection import get_sync_session
from database.models import PaymentHistory as DBPaymentHistory
db_available = True
except ImportError:
pass
payment_intent_id = session.get("payment_intent")
subscription_id = session.get("subscription")
if db_available and session_id:
try:
with get_sync_session() as db_session:
from sqlalchemy import or_
filters = [DBPaymentHistory.stripe_payment_intent_id == session_id]
if payment_intent_id:
filters.append(DBPaymentHistory.stripe_payment_intent_id == payment_intent_id)
if subscription_id:
filters.append(DBPaymentHistory.stripe_invoice_id == subscription_id)
existing = db_session.query(DBPaymentHistory).filter(or_(*filters)).first()
if existing:
logger.info("Checkout session %s already processed (pi=%s sub=%s). Skipping.",
session_id, payment_intent_id, subscription_id)
return
except Exception as e:
logger.error("Error checking PaymentHistory duplication: %s", e)
user = get_user_by_id(user_id)
if not user:
return
# Check if it's a credit purchase
if metadata.get("type") == "credits":
credits = int(metadata.get("credits", 0))
add_credits(user_id, credits)
# Log to PaymentHistory
if db_available and session_id:
try:
with get_sync_session() as db_session:
payment = DBPaymentHistory(
user_id=user_id,
stripe_payment_intent_id=payment_intent_id or session_id,
stripe_invoice_id=session.get("invoice") or session.get("subscription"),
amount_cents=session.get("amount_total") or 0,
currency=session.get("currency") or "usd",
payment_type="credits",
status="succeeded",
description=f"Achat de {credits} crédits",
)
db_session.add(payment)
db_session.commit()
except Exception as e:
logger.error("Failed to write credits payment to history: %s", e)
# Send Email
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="credits_purchased",
details={"credits": credits}
)
except Exception as e:
logger.error("Failed to send credit purchase email: %s", e)
# Send Telegram notification
try:
from utils.telegram import send_telegram_notification
import asyncio
msg = (
f"💰 *Achat de crédits !*\n\n"
f"• *Utilisateur* : `{user.name or 'Non renseigné'}` (`{user.email}`)\n"
f"• *Crédits achetés* : `{credits}`\n"
f"• *Montant* : `{session.get('amount_total', 0) / 100:.2f} {session.get('currency', 'eur').upper()}`\n"
f"• *ID Transaction* : `{session_id}`"
)
asyncio.create_task(send_telegram_notification(msg))
except Exception as tel_err:
logger.error(f"Failed to send telegram notification for credits checkout: {tel_err}")
return
# It's a subscription
plan = metadata.get("plan")
if plan:
subscription_raw = session.get("subscription")
subscription_id = None
subscription_ends_at = None
if isinstance(subscription_raw, str):
# Not expanded — fetch subscription to get period end
try:
sub = stripe.Subscription.retrieve(subscription_raw)
subscription_id = sub["id"]
if sub.get("current_period_end"):
from datetime import timezone
subscription_ends_at = datetime.fromtimestamp(
sub["current_period_end"], tz=timezone.utc
)
except Exception:
subscription_id = subscription_raw
elif subscription_raw:
# Expanded subscription object (from sync path)
subscription_id = subscription_raw.get("id")
period_end = subscription_raw.get("current_period_end")
if period_end:
subscription_ends_at = datetime.fromtimestamp(period_end, tz=timezone.utc)
# Derive tier from plan (DB constraint: only 'free' or 'pro')
tier = "pro" if plan in ("pro", "business", "enterprise") else "free"
is_new_sub = (
user.stripe_subscription_id != subscription_id
or user.subscription_status != SubscriptionStatus.ACTIVE.value
or user.plan != plan
)
update_user(user_id, {
"plan": plan,
"tier": tier,
"subscription_status": SubscriptionStatus.ACTIVE.value,
"stripe_subscription_id": subscription_id,
"stripe_customer_id": session.get("customer") or None,
"subscription_ends_at": subscription_ends_at,
"cancel_at_period_end": False,
"docs_translated_this_month": 0,
"pages_translated_this_month": 0,
})
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:
with get_sync_session() as db_session:
payment = DBPaymentHistory(
user_id=user_id,
stripe_payment_intent_id=payment_intent_id or session_id,
stripe_invoice_id=subscription_id or session.get("invoice"),
amount_cents=session.get("amount_total") or 0,
currency=session.get("currency") or "usd",
payment_type="subscription",
status="succeeded",
description=f"Abonnement au forfait {plan}",
)
db_session.add(payment)
db_session.commit()
except Exception as e:
logger.error("Failed to write subscription payment to history: %s", e)
# Send activation email
if is_new_sub:
try:
from services.email_service import send_subscription_email_async
ends_str = subscription_ends_at.strftime("%d/%m/%Y") if subscription_ends_at else ""
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="activated",
details={"plan_name": plan, "ends_at": ends_str}
)
except Exception as e:
logger.error("Failed to send activation email: %s", e)
# Send Telegram notification
try:
from utils.telegram import send_telegram_notification
import asyncio
ends_str = subscription_ends_at.strftime("%d/%m/%Y") if subscription_ends_at else "Non spécifiée"
msg = (
f"⭐ *Nouvel Abonnement Activé !*\n\n"
f"• *Utilisateur* : `{user.name or 'Non renseigné'}` (`{user.email}`)\n"
f"• *Forfait* : `{plan.upper()}`\n"
f"• *Montant* : `{session.get('amount_total', 0) / 100:.2f} {session.get('currency', 'eur').upper()}`\n"
f"• *Date de fin* : {ends_str}"
)
asyncio.create_task(send_telegram_notification(msg))
except Exception as tel_err:
logger.error(f"Failed to send telegram notification for subscription activation: {tel_err}")
async def handle_subscription_updated(subscription: Dict):
"""Handle subscription updates"""
metadata = subscription.get("metadata", {}) or {}
user_id = metadata.get("user_id")
if not user_id:
return
user = get_user_by_id(user_id)
if not user:
return
status_map = {
"active": SubscriptionStatus.ACTIVE,
"past_due": SubscriptionStatus.PAST_DUE,
"canceled": SubscriptionStatus.CANCELED,
"trialing": SubscriptionStatus.TRIALING,
"paused": SubscriptionStatus.PAUSED,
}
stripe_status = subscription.get("status", "active")
status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE)
stripe_cancel_at_period_end = subscription.get("cancel_at_period_end", False)
is_newly_cancelling = stripe_cancel_at_period_end and not user.cancel_at_period_end
period_end = subscription.get("current_period_end")
ends_str = ""
if period_end:
ends_str = datetime.fromtimestamp(period_end, tz=timezone.utc).strftime("%d/%m/%Y")
period_end = subscription.get("current_period_end")
update_user(user_id, {
"subscription_status": status.value,
"cancel_at_period_end": stripe_cancel_at_period_end,
"subscription_ends_at": datetime.fromtimestamp(period_end, tz=timezone.utc) if period_end else None,
})
# Send cancellation email if they just selected to cancel
if is_newly_cancelling:
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="cancelled",
details={"ends_at": ends_str}
)
except Exception as e:
logger.error("Failed to send cancellation email in handle_subscription_updated: %s", e)
# Send Telegram notification for cancellation request
try:
from utils.telegram import send_telegram_notification
import asyncio
msg = (
f"🔕 *Désinscription programmée (Stripe)*\n\n"
f"• *Utilisateur* : `{user.name or 'Non renseigné'}` (`{user.email}`)\n"
f"• *Date de fin d'accès* : {ends_str}"
)
asyncio.create_task(send_telegram_notification(msg))
except Exception as tel_err:
logger.error(f"Failed to send telegram notification for subscription cancellation request: {tel_err}")
async def handle_subscription_deleted(subscription: Dict):
"""Handle subscription cancellation (actually ended)"""
metadata = subscription.get("metadata", {}) or {}
user_id = metadata.get("user_id")
if not user_id:
return
user = get_user_by_id(user_id)
if not user:
return
had_active_sub = user.plan != PlanType.FREE or user.tier != "free"
update_user(user_id, {
"plan": PlanType.FREE.value,
"tier": "free",
"subscription_status": SubscriptionStatus.CANCELED.value,
"stripe_subscription_id": None,
})
# Send ended email
if had_active_sub:
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="ended",
details={}
)
except Exception as e:
logger.error("Failed to send subscription ended email: %s", e)
# Send Telegram notification for ended subscription
try:
from utils.telegram import send_telegram_notification
import asyncio
msg = (
f"❌ *Abonnement Terminé / Expiré (Stripe)*\n\n"
f"• *Utilisateur* : `{user.name or 'Non renseigné'}` (`{user.email}`)\n"
f"• *Statut* : Retour au forfait `FREE`"
)
asyncio.create_task(send_telegram_notification(msg))
except Exception as tel_err:
logger.error(f"Failed to send telegram notification for subscription ended: {tel_err}")
async def handle_payment_failed(invoice: Dict):
"""Handle failed payment — set subscription status to PAST_DUE and send email"""
customer_id = invoice.get("customer")
if not customer_id:
return
# Find user by stripe_customer_id
try:
from database.connection import get_sync_session
from database.models import User as DBUser
with get_sync_session() as session:
db_user = (
session.query(DBUser)
.filter(DBUser.stripe_customer_id == customer_id)
.first()
)
if db_user:
user_id = str(db_user.id)
db_user.subscription_status = SubscriptionStatus.PAST_DUE
session.commit()
logger.warning(
"Payment failed for customer %s (user %s) — status set to past_due",
customer_id, user_id,
)
# Send email
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=db_user.email,
user_name=db_user.name,
event_type="payment_failed",
details={}
)
except Exception as e:
logger.error("Failed to send payment failed email: %s", e)
# Send Telegram notification
try:
from utils.telegram import send_telegram_notification
import asyncio
msg = (
f"⚠️ *Échec de paiement (Stripe)*\n\n"
f"• *Utilisateur* : `{db_user.name or 'Non renseigné'}` (`{db_user.email}`)\n"
f"• *Statut* : Facture impayée, forfait passé en `PAST_DUE`"
)
asyncio.create_task(send_telegram_notification(msg))
except Exception as tel_err:
logger.error(f"Failed to send telegram notification for payment failed: {tel_err}")
except Exception as exc:
logger.error("handle_payment_failed DB error: %s", exc)
async def handle_invoice_paid(invoice: Dict):
"""Extend subscription_ends_at when a recurring invoice is paid."""
customer_id = invoice.get("customer")
if not customer_id:
return
subscription_id = invoice.get("subscription")
period_end = invoice.get("period_end") or invoice.get("lines", {}).get("data", [{}])[0].get("period", {}).get("end")
try:
from database.connection import get_sync_session
from database.models import User as DBUser
with get_sync_session() as session:
db_user = (
session.query(DBUser)
.filter(DBUser.stripe_customer_id == customer_id)
.first()
)
if not db_user:
return
if subscription_id and db_user.stripe_subscription_id != subscription_id:
# The paid invoice belongs to a different subscription; do not update.
logger.warning(
"Invoice paid for customer %s but subscription id mismatch (expected %s, got %s)",
customer_id, db_user.stripe_subscription_id, subscription_id,
)
return
if period_end:
new_end = datetime.fromtimestamp(period_end, tz=timezone.utc)
if db_user.subscription_ends_at is None or new_end > db_user.subscription_ends_at:
db_user.subscription_ends_at = new_end
db_user.updated_at = datetime.now(timezone.utc)
session.commit()
logger.info(
"Extended subscription_ends_at for user %s to %s",
db_user.id, new_end.isoformat(),
)
except Exception as exc:
logger.error("handle_invoice_paid error: %s", exc)
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
"""Cancel a user's subscription at period end."""
if not is_stripe_configured():
return {"error": "Stripe not configured"}
user = get_user_by_id(user_id)
if not user or not user.stripe_subscription_id:
return {"error": "No active subscription found"}
try:
subscription = stripe.Subscription.retrieve(user.stripe_subscription_id)
if subscription.customer != user.stripe_customer_id:
return {"error": "Subscription does not belong to current user"}
subscription = stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=True,
)
cancel_at = None
if subscription.cancel_at:
cancel_at = datetime.fromtimestamp(subscription.cancel_at, tz=timezone.utc)
subscription_ends_at = None
ends_str = ""
if subscription.current_period_end:
subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end, tz=timezone.utc)
ends_str = datetime.fromtimestamp(subscription.current_period_end, tz=timezone.utc).strftime("%d/%m/%Y")
is_new_cancel = not user.cancel_at_period_end
update_user(user_id, {
"cancel_at_period_end": True,
"subscription_ends_at": subscription_ends_at,
})
# Send cancellation confirmation email
if is_new_cancel:
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="cancelled",
details={"ends_at": ends_str}
)
except Exception as e:
logger.error("Failed to send cancellation email in cancel_subscription: %s", e)
return {
"status": "canceling",
"cancel_at": cancel_at,
"subscription_ends_at": subscription_ends_at,
}
except Exception as e:
return {"error": str(e)}
# ─── cached portal config id (created once per process) ───────────────────────
_PORTAL_CONFIG_ID: Optional[str] = None
def _get_or_create_portal_config() -> Optional[str]:
"""
Return a Stripe Customer Portal configuration ID.
Creates one programmatically if none exists yet, so no Stripe Dashboard
manual setup is required.
"""
global _PORTAL_CONFIG_ID
if _PORTAL_CONFIG_ID:
return _PORTAL_CONFIG_ID
try:
# Re-use the first existing configuration if there is one
existing = stripe.billing_portal.Configuration.list(active=True, limit=1)
if existing.data:
_PORTAL_CONFIG_ID = existing.data[0].id
return _PORTAL_CONFIG_ID
# Create a minimal configuration programmatically
config = stripe.billing_portal.Configuration.create(
business_profile={
"headline": "Wordly.art — Gérer mon abonnement",
},
features={
"invoice_history": {"enabled": True},
"payment_method_update": {"enabled": True},
"subscription_cancel": {
"enabled": True,
"mode": "at_period_end", # keeps access until end of paid period
"proration_behavior": "none",
"cancellation_reason": {
"enabled": True,
"options": [
"too_expensive",
"missing_features",
"switched_service",
"unused",
"other",
],
},
},
"customer_update": {
"enabled": True,
"allowed_updates": ["email", "name", "address"],
},
},
)
_PORTAL_CONFIG_ID = config.id
logger.info("Stripe portal configuration created: %s", _PORTAL_CONFIG_ID)
return _PORTAL_CONFIG_ID
except Exception as e:
logger.error("Failed to get/create Stripe portal config: %s", e)
return None
async def get_billing_portal_url(user_id: str) -> Optional[str]:
"""Get Stripe billing portal URL for customer"""
if not is_stripe_configured():
return None
user = get_user_by_id(user_id)
if not user or not user.stripe_customer_id:
return None
try:
config_id = _get_or_create_portal_config()
kwargs: Dict[str, Any] = {
"customer": user.stripe_customer_id,
"return_url": (
f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}"
"/dashboard/profile?tab=subscription"
),
}
if config_id:
kwargs["configuration"] = config_id
session = stripe.billing_portal.Session.create(**kwargs)
return session.url
except Exception as e:
logger.error("get_billing_portal_url error: %s", e)
return None