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)
1220 lines
47 KiB
Python
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
|
|
|