fix: show target-language-specific translations in preview + rename migration
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Two issues found in screenshot review: 1. Glossary preview always showed English 'target' field (foie → liver) instead of the language-specific translation. Now looks up term.translations[targetLang] first, falls back to term.target. When target is Persian, preview shows 'foie → کبد' (Persian). 2. Previous migration b7c8d9e0f1a2 was already applied on server before the rename was added. New migration c8d9e0f1a2b3 handles the rename separately: glossaries with target_language='multi' get their name changed from 'Anglais' to 'Multilingue'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""Rename multilingual glossaries from 'Anglais' to 'Multilingue'
|
||||
|
||||
Revision ID: c8d9e0f1a2b3
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-05-31
|
||||
|
||||
Previous migration b7c8d9e0f1a2 set target_language='multi' but the rename
|
||||
part was added after the migration was already applied on the server.
|
||||
This migration handles the rename separately.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "c8d9e0f1a2b3"
|
||||
down_revision = "b7c8d9e0f1a2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Rename glossaries that are multilingual but still have "Anglais" in name
|
||||
op.execute("""
|
||||
UPDATE glossaries
|
||||
SET name = REPLACE(name, 'Anglais', 'Multilingue')
|
||||
WHERE target_language = 'multi'
|
||||
AND name LIKE '%%Anglais%%'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
UPDATE glossaries
|
||||
SET name = REPLACE(name, 'Multilingue', 'Anglais')
|
||||
WHERE target_language = 'multi'
|
||||
AND name LIKE '%%Multilingue%%'
|
||||
""")
|
||||
@@ -546,14 +546,18 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
|
||||
</div>
|
||||
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1">
|
||||
{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 (
|
||||
<div key={t.id || i} className="flex justify-between items-center bg-brand-muted/30 dark:bg-white/5 px-2 py-1.5 rounded-md text-xs">
|
||||
<span className="font-semibold text-brand-dark/75 dark:text-white/70 truncate max-w-[220px]">
|
||||
{t.source} ➔ {t.target}
|
||||
{t.source} ➔ {displayTarget}
|
||||
</span>
|
||||
<span className="inline-block w-1.5 h-1.5 bg-brand-accent rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{selectedGlossaryDetail.terms.length > 4 && (
|
||||
<span className="text-[10px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1">
|
||||
+ {selectedGlossaryDetail.terms.length - 4} autres termes
|
||||
|
||||
@@ -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"""
|
||||
<p>Bonjour {display_name},</p>
|
||||
<p>Nous sommes ravis de vous compter parmi nos membres Premium ! Votre abonnement au forfait <strong>{plan_name}</strong> est désormais actif.</p>
|
||||
<div class="info-box">
|
||||
<div class="info-title">Détails de votre abonnement :</div>
|
||||
<p style="margin: 4px 0;"><strong>Forfait :</strong> {plan_name}</p>
|
||||
{"<p style='margin: 4px 0;'><strong>Date de renouvellement :</strong> " + ends_at + "</p>" if ends_at else ""}
|
||||
</div>
|
||||
<p>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.</p>
|
||||
<div class="button-container">
|
||||
<a href="{dashboard_url}" class="button">Accéder à mon tableau de bord</a>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<p>Bonjour {display_name},</p>
|
||||
<p>Nous vous confirmons que l'annulation de votre abonnement a bien été prise en compte à votre demande.</p>
|
||||
<div class="info-box">
|
||||
<div class="info-title">Informations importantes :</div>
|
||||
{"<p style='margin: 4px 0;'><strong>Accès actif jusqu'au :</strong> " + ends_at + "</p>" if ends_at else ""}
|
||||
<p style="margin: 4px 0;">Aucun autre prélèvement ne sera effectué.</p>
|
||||
</div>
|
||||
<p>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.</p>
|
||||
<p>Si vous changez d'avis, vous pouvez réactiver votre abonnement à tout moment depuis votre profil.</p>
|
||||
<div class="button-container">
|
||||
<a href="{profile_url}" class="button">Gérer mon compte</a>
|
||||
</div>
|
||||
"""
|
||||
elif event_type == "ended":
|
||||
subject = "Votre accès premium a expiré - Office Translator"
|
||||
header_title = "Accès premium terminé"
|
||||
content_html = f"""
|
||||
<p>Bonjour {display_name},</p>
|
||||
<p>Votre abonnement Office Translator a pris fin et votre compte a été basculé vers le forfait <strong>Gratuit</strong>.</p>
|
||||
<p>Vous pouvez toujours traduire des documents dans la limite de votre quota gratuit. Vos documents et votre historique restent disponibles.</p>
|
||||
<p>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.</p>
|
||||
<div class="button-container">
|
||||
<a href="{pricing_url}" class="button">Voir nos forfaits</a>
|
||||
</div>
|
||||
"""
|
||||
elif event_type == "payment_failed":
|
||||
subject = "Échec de paiement - Action requise - Office Translator ⚠️"
|
||||
header_title = "Problème de paiement"
|
||||
content_html = f"""
|
||||
<p>Bonjour {display_name},</p>
|
||||
<p>Nous n'avons pas pu prélever le règlement de votre abonnement Office Translator. Notre tentative de paiement a échoué.</p>
|
||||
<p>Pour éviter toute interruption de votre service premium, nous vous invitons à mettre à jour vos coordonnées de paiement dès que possible.</p>
|
||||
<div class="button-container">
|
||||
<a href="{profile_url}" class="button">Mettre à jour mon moyen de paiement</a>
|
||||
</div>
|
||||
<p>Si vous avez des questions, n'hésitez pas à répondre à cet e-mail.</p>
|
||||
"""
|
||||
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"""
|
||||
<p>Bonjour {display_name},</p>
|
||||
<p>Nous vous remercions pour votre achat ! Vos crédits supplémentaires ont bien été crédités sur votre compte.</p>
|
||||
<div class="info-box">
|
||||
<div class="info-title">Détails de l'achat :</div>
|
||||
<p style="margin: 4px 0;"><strong>Crédits achetés :</strong> +{credits}</p>
|
||||
<p style="margin: 4px 0;">Chaque crédit vous permet de traduire une page de document supplémentaire.</p>
|
||||
</div>
|
||||
<p>Vous pouvez suivre votre solde de crédits et l'historique de vos traductions depuis votre tableau de bord.</p>
|
||||
<div class="button-container">
|
||||
<a href="{dashboard_url}" class="button">Traduire un document</a>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
return False
|
||||
|
||||
full_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #334155;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
.wrapper {{
|
||||
width: 100%;
|
||||
background-color: #f8fafc;
|
||||
padding: 40px 0;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
}}
|
||||
.header h1 {{
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}}
|
||||
.content {{
|
||||
padding: 32px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.content p {{
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}}
|
||||
.button-container {{
|
||||
text-align: center;
|
||||
margin: 32px 0;
|
||||
}}
|
||||
.button {{
|
||||
background-color: #4f46e5;
|
||||
color: #ffffff !important;
|
||||
padding: 12px 32px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.2);
|
||||
}}
|
||||
.footer {{
|
||||
background-color: #f1f5f9;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}}
|
||||
.footer p {{
|
||||
margin: 4px 0;
|
||||
}}
|
||||
.info-box {{
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}}
|
||||
.info-title {{
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{header_title}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{content_html}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Cet e-mail automatique a été envoyé par <strong>Office Translator</strong>.</p>
|
||||
<p>© {datetime.now().year} Office Translator. Tous droits réservés.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return await send_email_async(to_email, subject, full_html)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user