From b4096fd2ca6a01ec4c74d42dca6b0ffdde88e7b1 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 31 May 2026 23:22:50 +0200 Subject: [PATCH] fix: show target-language-specific translations in preview + rename migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...e0f1a2b3_rename_multilingual_glossaries.py | 40 ++++ .../dashboard/translate/GlossarySelector.tsx | 10 +- services/email_service.py | 208 ++++++++++++++++++ services/payment_service.py | 187 +++++++++++++++- 4 files changed, 434 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/c8d9e0f1a2b3_rename_multilingual_glossaries.py diff --git a/alembic/versions/c8d9e0f1a2b3_rename_multilingual_glossaries.py b/alembic/versions/c8d9e0f1a2b3_rename_multilingual_glossaries.py new file mode 100644 index 0000000..cb102aa --- /dev/null +++ b/alembic/versions/c8d9e0f1a2b3_rename_multilingual_glossaries.py @@ -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%%' + """) diff --git a/frontend/src/app/dashboard/translate/GlossarySelector.tsx b/frontend/src/app/dashboard/translate/GlossarySelector.tsx index 326674d..c45f4c8 100644 --- a/frontend/src/app/dashboard/translate/GlossarySelector.tsx +++ b/frontend/src/app/dashboard/translate/GlossarySelector.tsx @@ -546,14 +546,18 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary ) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
- {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 (
- {t.source} ➔ {t.target} + {t.source} ➔ {displayTarget}
- ))} + ); + })} {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""" + + + + + + +
+
+
+

{header_title}

+
+
+ {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,