- {selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => (
+ {selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => {
+ const translations = t.translations || {};
+ const displayTarget = translations[targetLang] || t.target;
+ return (
- ))}
+ );
+ })}
{selectedGlossaryDetail.terms.length > 4 && (
+ {selectedGlossaryDetail.terms.length - 4} autres termes
diff --git a/services/email_service.py b/services/email_service.py
index 08d864a..67ed0e3 100644
--- a/services/email_service.py
+++ b/services/email_service.py
@@ -117,3 +117,211 @@ async def send_email_async(to: str, subject: str, body: str) -> bool:
except Exception as e:
logger.error(f"Failed to send email (async) to {to}: {e}")
return False
+
+
+async def send_subscription_email_async(
+ to_email: str,
+ user_name: str,
+ event_type: str,
+ details: dict
+) -> bool:
+ """
+ Send subscription transactional email in French.
+ event_type can be: 'activated', 'cancelled', 'ended', 'payment_failed', 'credits_purchased'
+ """
+ display_name = user_name or to_email.split("@")[0]
+
+ frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
+ dashboard_url = f"{frontend_url}/dashboard"
+ profile_url = f"{frontend_url}/dashboard/profile?tab=subscription"
+ pricing_url = f"{frontend_url}/pricing"
+
+ subject = "Notification Office Translator"
+ header_title = "Office Translator"
+ content_html = ""
+
+ if event_type == "activated":
+ plan_name = details.get("plan_name", "Premium").capitalize()
+ ends_at = details.get("ends_at", "")
+ subject = "Votre abonnement Office Translator est activé ! 🚀"
+ header_title = "Bienvenue chez Office Translator"
+ content_html = f"""
+ Bonjour {display_name},
+ Nous sommes ravis de vous compter parmi nos membres Premium ! Votre abonnement au forfait {plan_name} est désormais actif.
+
+
Détails de votre abonnement :
+
Forfait : {plan_name}
+ {"
Date de renouvellement : " + ends_at + "
" if ends_at else ""}
+
+ Vous pouvez dès à présent profiter de toutes les fonctionnalités premium, y compris la traduction illimitée de documents et l'accès à nos meilleurs modèles d'IA.
+
+ """
+ elif event_type == "cancelled":
+ ends_at = details.get("ends_at", "")
+ subject = "Confirmation d'annulation de votre abonnement - Office Translator"
+ header_title = "Abonnement annulé"
+ content_html = f"""
+ Bonjour {display_name},
+ Nous vous confirmons que l'annulation de votre abonnement a bien été prise en compte à votre demande.
+
+
Informations importantes :
+ {"
Accès actif jusqu'au : " + ends_at + "
" if ends_at else ""}
+
Aucun autre prélèvement ne sera effectué.
+
+ Vous conservez un accès complet à toutes les fonctionnalités premium de votre forfait jusqu'à la fin de la période de facturation en cours.
+ Si vous changez d'avis, vous pouvez réactiver votre abonnement à tout moment depuis votre profil.
+
+ """
+ elif event_type == "ended":
+ subject = "Votre accès premium a expiré - Office Translator"
+ header_title = "Accès premium terminé"
+ content_html = f"""
+ Bonjour {display_name},
+ Votre abonnement Office Translator a pris fin et votre compte a été basculé vers le forfait Gratuit.
+ Vous pouvez toujours traduire des documents dans la limite de votre quota gratuit. Vos documents et votre historique restent disponibles.
+ Pour retrouver l'accès à la traduction rapide, sans limites et à nos modèles IA avancés, vous pouvez vous réabonner en un clic.
+
+ """
+ elif event_type == "payment_failed":
+ subject = "Échec de paiement - Action requise - Office Translator ⚠️"
+ header_title = "Problème de paiement"
+ content_html = f"""
+ Bonjour {display_name},
+ Nous n'avons pas pu prélever le règlement de votre abonnement Office Translator. Notre tentative de paiement a échoué.
+ Pour éviter toute interruption de votre service premium, nous vous invitons à mettre à jour vos coordonnées de paiement dès que possible.
+
+ Si vous avez des questions, n'hésitez pas à répondre à cet e-mail.
+ """
+ elif event_type == "credits_purchased":
+ credits = details.get("credits", 0)
+ subject = "Achat de crédits réussi - Office Translator 💳"
+ header_title = "Crédits ajoutés"
+ content_html = f"""
+ Bonjour {display_name},
+ Nous vous remercions pour votre achat ! Vos crédits supplémentaires ont bien été crédités sur votre compte.
+
+
Détails de l'achat :
+
Crédits achetés : +{credits}
+
Chaque crédit vous permet de traduire une page de document supplémentaire.
+
+ Vous pouvez suivre votre solde de crédits et l'historique de vos traductions depuis votre tableau de bord.
+
+ """
+ else:
+ return False
+
+ full_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {content_html}
+
+
+
+
+
+
+"""
+ return await send_email_async(to_email, subject, full_html)
+
diff --git a/services/payment_service.py b/services/payment_service.py
index 20bdc2d..25196fd 100644
--- a/services/payment_service.py
+++ b/services/payment_service.py
@@ -268,16 +268,74 @@ async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
async def handle_checkout_completed(session: Dict):
"""Handle successful checkout"""
- metadata = session.get("metadata", {})
+ 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
+ db_available = False
+ try:
+ from database.connection import get_sync_session
+ from database.models import PaymentHistory as DBPaymentHistory
+ db_available = True
+ except ImportError:
+ pass
+
+ if db_available and session_id:
+ try:
+ with get_sync_session() as db_session:
+ existing = db_session.query(DBPaymentHistory).filter(
+ DBPaymentHistory.stripe_payment_intent_id == session_id
+ ).first()
+ if existing:
+ logger.info("Checkout session %s already processed. Skipping.", session_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=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)
return
# It's a subscription
@@ -310,6 +368,12 @@ async def handle_checkout_completed(session: Dict):
# 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,
@@ -323,14 +387,51 @@ async def handle_checkout_completed(session: Dict):
})
logger.info("Checkout synced: user %s → plan=%s tier=%s sub=%s", user_id, plan, tier, subscription_id)
+ # 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=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)
+
async def handle_subscription_updated(subscription: Dict):
"""Handle subscription updates"""
- metadata = subscription.get("metadata", {})
+ 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,
@@ -343,38 +444,79 @@ async def handle_subscription_updated(subscription: Dict):
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:
+ from datetime import timezone
+ ends_str = datetime.fromtimestamp(period_end, tz=timezone.utc).strftime("%d/%m/%Y")
+
update_user(user_id, {
"subscription_status": status.value,
- "cancel_at_period_end": subscription.get("cancel_at_period_end", False),
+ "cancel_at_period_end": stripe_cancel_at_period_end,
"subscription_ends_at": datetime.fromtimestamp(
subscription.get("current_period_end", 0)
).isoformat() if subscription.get("current_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)
+
async def handle_subscription_deleted(subscription: Dict):
- """Handle subscription cancellation"""
- metadata = subscription.get("metadata", {})
+ """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.value 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)
+
async def handle_payment_failed(invoice: Dict):
- """Handle failed payment — set subscription status to PAST_DUE"""
+ """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
- user = None
try:
from database.connection import get_sync_session
from database.models import User as DBUser
@@ -393,6 +535,18 @@ async def handle_payment_failed(invoice: Dict):
"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)
except Exception as exc:
logger.error("handle_payment_failed DB error: %s", exc)
@@ -417,14 +571,31 @@ async def cancel_subscription(user_id: str) -> Dict[str, Any]:
cancel_at = datetime.fromtimestamp(subscription.cancel_at).isoformat()
subscription_ends_at = None
+ ends_str = ""
if subscription.current_period_end:
subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end).isoformat()
+ ends_str = datetime.fromtimestamp(subscription.current_period_end).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,