""" 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