From 776fc3aadc14e1cc415286cf008fa4eb85f21164 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 6 Mar 2026 19:56:37 +0300 Subject: [PATCH] feat: guest purchase delivery & activation system - Add PENDING_ACTIVATION status for users with existing subscriptions - Add activation endpoint POST /landing/activate/{token} - Send email notifications on delivery and pending activation - Add 3 email templates (delivered, activation required, gift received) in 5 languages - Extract purchase status response builder to reusable helper - Move activation logic to service layer - Add header injection protection in email service - Add Literal type guard for contact_type parameter - Fix _mask_email crash on malformed input - Pre-resolve notification params before commit to avoid DetachedInstanceError --- app/cabinet/routes/admin_landings.py | 59 +++- app/cabinet/routes/landing.py | 120 ++++++-- app/cabinet/services/email_service.py | 4 + app/cabinet/services/email_templates.py | 269 ++++++++++++++++++ app/database/models.py | 1 + app/services/guest_purchase_service.py | 248 +++++++++++++++- app/services/notification_delivery_service.py | 5 + app/services/payment/common.py | 1 + 8 files changed, 660 insertions(+), 47 deletions(-) diff --git a/app/cabinet/routes/admin_landings.py b/app/cabinet/routes/admin_landings.py index e2d1fc78..a25b61cf 100644 --- a/app/cabinet/routes/admin_landings.py +++ b/app/cabinet/routes/admin_landings.py @@ -1,10 +1,11 @@ """Admin routes for landing page management in cabinet.""" from datetime import datetime +from urllib.parse import urlparse import structlog from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy.ext.asyncio import AsyncSession from app.cabinet.utils.locale import ( @@ -21,7 +22,7 @@ from app.database.crud.landing import ( update_landing, update_landing_order, ) -from app.database.models import User +from app.database.models import LandingPage, User from ..dependencies import get_cabinet_db, require_permission @@ -72,11 +73,15 @@ class LandingFeatureInput(BaseModel): class LandingPaymentMethodInput(BaseModel): - method_id: str - display_name: str - description: str | None = None - icon_url: str | None = None + method_id: str = Field(max_length=50) + display_name: str = Field(max_length=200) + description: str | None = Field(default=None, max_length=500) + icon_url: str | None = Field(default=None, max_length=500) sort_order: int = 0 + min_amount_kopeks: int | None = None + max_amount_kopeks: int | None = None + currency: str | None = Field(default=None, max_length=10) + return_url: str | None = Field(default=None, max_length=500) @field_validator('icon_url', mode='before') @classmethod @@ -87,6 +92,42 @@ class LandingPaymentMethodInput(BaseModel): raise ValueError('icon_url must use HTTPS or be a relative path') return v + @field_validator('return_url', mode='before') + @classmethod + def validate_return_url(cls, v: str | None) -> str | None: + if not v: + return None + if not v.startswith('https://'): + raise ValueError('return_url must use HTTPS') + parsed = urlparse(v) + if not parsed.hostname or parsed.username or parsed.password: + raise ValueError('return_url must be a valid HTTPS URL without credentials') + return v + + @field_validator('currency', mode='before') + @classmethod + def validate_currency(cls, v: str | None) -> str | None: + if not v: + return None + return v.strip().upper() + + @field_validator('min_amount_kopeks', 'max_amount_kopeks') + @classmethod + def validate_amounts(cls, v: int | None) -> int | None: + if v is not None and v < 0: + raise ValueError('Amount cannot be negative') + return v + + @model_validator(mode='after') + def validate_amount_range(self) -> 'LandingPaymentMethodInput': + if ( + self.min_amount_kopeks is not None + and self.max_amount_kopeks is not None + and self.min_amount_kopeks > self.max_amount_kopeks + ): + raise ValueError('min_amount_kopeks cannot be greater than max_amount_kopeks') + return self + class LandingCreateRequest(BaseModel): slug: str = Field(pattern=r'^[a-z0-9\-]+$', min_length=1, max_length=100) @@ -470,7 +511,7 @@ async def toggle_landing_active( # ============ Helpers ============ -def _landing_to_detail(landing) -> LandingDetailResponse: +def _landing_to_detail(landing: LandingPage) -> LandingDetailResponse: """Convert a LandingPage model to LandingDetailResponse. Admin detail view returns full locale dicts for all text fields. @@ -491,6 +532,10 @@ def _landing_to_detail(landing) -> LandingDetailResponse: description=m.get('description'), icon_url=m.get('icon_url'), sort_order=m.get('sort_order', 0), + min_amount_kopeks=m.get('min_amount_kopeks'), + max_amount_kopeks=m.get('max_amount_kopeks'), + currency=m.get('currency'), + return_url=m.get('return_url'), ) for m in (landing.payment_methods or []) ] diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 73ca2b45..0d0552b9 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -12,9 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text from app.config import settings from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token -from app.database.models import Tariff +from app.database.models import GuestPurchase, LandingPage, Tariff from app.services.guest_purchase_service import ( GuestPurchaseError, + activate_purchase as activate_guest_purchase, create_purchase, validate_and_calculate, ) @@ -62,6 +63,9 @@ class LandingPaymentMethod(BaseModel): description: str | None = None icon_url: str | None = None sort_order: int = 0 + min_amount_kopeks: int | None = None + max_amount_kopeks: int | None = None + currency: str | None = None class LandingConfigResponse(BaseModel): @@ -122,8 +126,10 @@ class PurchaseStatusResponse(BaseModel): subscription_crypto_link: str | None = None is_gift: bool = False contact_value: str | None = None + recipient_contact_value: str | None = None period_days: int | None = None tariff_name: str | None = None + gift_message: str | None = None # ============ Helpers ============ @@ -141,6 +147,43 @@ def _mask_contact(value: str) -> str: return value[:3] + '***' +_SUBSCRIPTION_URL_EXPIRY_HOURS = 24 + + +def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusResponse: + """Build a PurchaseStatusResponse from a GuestPurchase record.""" + tariff_name = purchase.tariff.name if purchase.tariff else None + + subscription_url = None + subscription_crypto_link = None + if purchase.delivered_at and purchase.subscription_url: + age = datetime.now(UTC) - purchase.delivered_at + if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS): + subscription_url = purchase.subscription_url + subscription_crypto_link = purchase.subscription_crypto_link + + masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None + + recipient_contact_value = None + gift_message = None + if purchase.is_gift: + if purchase.gift_recipient_value: + recipient_contact_value = _mask_contact(purchase.gift_recipient_value) + gift_message = purchase.gift_message + + return PurchaseStatusResponse( + status=purchase.status, + subscription_url=subscription_url, + subscription_crypto_link=subscription_crypto_link, + is_gift=purchase.is_gift, + contact_value=masked_contact, + recipient_contact_value=recipient_contact_value, + period_days=purchase.period_days, + tariff_name=tariff_name, + gift_message=gift_message, + ) + + def _period_label(days: int) -> str: """Human-readable label for a period in days.""" if days == 1: @@ -173,7 +216,7 @@ def _period_label(days: int) -> str: return f'{days} days' -async def _load_landing_tariffs(db: AsyncSession, landing) -> list[LandingTariff]: +async def _load_landing_tariffs(db: AsyncSession, landing: LandingPage) -> list[LandingTariff]: """Load tariffs for a landing page, filtered by allowed IDs and periods.""" allowed_ids = landing.allowed_tariff_ids or [] if not allowed_ids: @@ -256,30 +299,29 @@ async def get_purchase_status( detail='Purchase not found', ) - tariff_name = None - if purchase.tariff: - tariff_name = purchase.tariff.name + return _build_purchase_status_response(purchase) - # Only expose subscription URLs within 24 hours of delivery - subscription_url = None - subscription_crypto_link = None - if purchase.delivered_at and purchase.subscription_url: - age = datetime.now(UTC) - purchase.delivered_at - if age < timedelta(hours=24): - subscription_url = purchase.subscription_url - subscription_crypto_link = purchase.subscription_crypto_link - masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None +@router.post('/activate/{token}', response_model=PurchaseStatusResponse) +async def activate_purchase( + token: str, + raw_request: Request, + db: AsyncSession = Depends(get_cabinet_db), +): + """Activate a pending guest purchase, replacing the user's current subscription. - return PurchaseStatusResponse( - status=purchase.status, - subscription_url=subscription_url, - subscription_crypto_link=subscription_crypto_link, - is_gift=purchase.is_gift, - contact_value=masked_contact, - period_days=purchase.period_days, - tariff_name=tariff_name, - ) + No authentication required (token is the secret). + """ + client_ip = get_client_ip(raw_request) + if await RateLimitCache.is_ip_rate_limited(client_ip, 'activate_purchase', limit=5, window=60, fail_closed=True): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests') + + try: + purchase = await activate_guest_purchase(db, token) + except GuestPurchaseError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc + + return _build_purchase_status_response(purchase) @router.get('/{slug}', response_model=LandingConfigResponse) @@ -315,6 +357,9 @@ async def get_landing_config( description=m.get('description'), icon_url=m.get('icon_url'), sort_order=m.get('sort_order', 0), + min_amount_kopeks=m.get('min_amount_kopeks'), + max_amount_kopeks=m.get('max_amount_kopeks'), + currency=m.get('currency'), ) for m in raw_methods ] @@ -376,8 +421,9 @@ async def create_landing_purchase( ) # Validate payment method is available on this landing - available_method_ids = {m.get('method_id') for m in (landing.payment_methods or [])} - if body.payment_method not in available_method_ids: + raw_methods = landing.payment_methods or [] + method_config = next((m for m in raw_methods if m.get('method_id') == body.payment_method), None) + if method_config is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Payment method is not available on this landing page', @@ -389,6 +435,20 @@ async def create_landing_purchase( except GuestPurchaseError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc + # Validate amount against per-method min/max limits (before creating purchase record) + min_amount = method_config.get('min_amount_kopeks') + max_amount = method_config.get('max_amount_kopeks') + if min_amount is not None and amount_kopeks < min_amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Amount is below the minimum ({settings.format_price(min_amount)}) for this payment method', + ) + if max_amount is not None and amount_kopeks > max_amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Amount exceeds the maximum ({settings.format_price(max_amount)}) for this payment method', + ) + # Create purchase record (no commit yet — wait for payment creation) purchase = await create_purchase( db, @@ -406,9 +466,15 @@ async def create_landing_purchase( commit=False, ) - # Initiate payment via the configured provider + # Determine return URL: per-method override → default cabinet URL cabinet_base = (settings.CABINET_URL or '').rstrip('/') - return_url = f'{cabinet_base}/buy/success/{purchase.token}' + default_return_url = f'{cabinet_base}/buy/success/{purchase.token}' + method_return_url = method_config.get('return_url') + if method_return_url: + # Allow {token} placeholder in custom return URLs + return_url = method_return_url.replace('{token}', purchase.token) + else: + return_url = default_return_url payment_service = PaymentService() payment_result = await payment_service.create_guest_payment( diff --git a/app/cabinet/services/email_service.py b/app/cabinet/services/email_service.py index bc833a8f..995485d2 100644 --- a/app/cabinet/services/email_service.py +++ b/app/cabinet/services/email_service.py @@ -69,6 +69,10 @@ class EmailService: logger.warning('SMTP is not configured, cannot send email') return False + # Defensive: strip newlines to prevent header injection + to_email = to_email.strip().replace('\n', '').replace('\r', '') + subject = subject.replace('\n', '').replace('\r', '') + try: msg = MIMEMultipart('alternative') msg['Subject'] = subject diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index c27f022f..f76aab21 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -62,6 +62,9 @@ class EmailNotificationTemplates: NotificationType.PAYMENT_RECEIVED: self._payment_received_template, NotificationType.EMAIL_VERIFICATION: self._email_verification_template, NotificationType.PASSWORD_RESET: self._password_reset_template, + NotificationType.GUEST_SUBSCRIPTION_DELIVERED: self._guest_subscription_delivered_template, + NotificationType.GUEST_ACTIVATION_REQUIRED: self._guest_activation_required_template, + NotificationType.GUEST_GIFT_RECEIVED: self._guest_gift_received_template, } template_func = template_map.get(notification_type) @@ -1327,6 +1330,272 @@ class EmailNotificationTemplates: 'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language), } + # ============================================================================ + # Guest Purchase Templates + # ============================================================================ + + def _guest_subscription_delivered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: + """Template for guest subscription delivered notification.""" + subscription_url = html.escape(context.get('subscription_url', '')) + tariff_name = html.escape(context.get('tariff_name', '')) + period_days = context.get('period_days', 0) + success_page_url = html.escape(context.get('success_page_url', '')) + + subjects = { + 'ru': 'Ваша VPN подписка готова', + 'en': 'Your VPN subscription is ready', + 'zh': '您的VPN订阅已准备就绪', + 'ua': 'Ваша VPN підписка готова', + 'fa': 'اشتراک VPN شما آماده است', + } + + bodies = { + 'ru': f""" +

Ваша VPN подписка готова!

+
+

Тариф: {tariff_name}

+

Период: {period_days} дней

+
+

Ваша ссылка подписки:

+

{subscription_url}

+

Скопируйте ссылку и добавьте в ваше VPN-приложение.

+

Открыть страницу подписки

+ """, + 'en': f""" +

Your VPN subscription is ready!

+
+

Plan: {tariff_name}

+

Period: {period_days} days

+
+

Your subscription link:

+

{subscription_url}

+

Copy the link and add it to your VPN app.

+

Open subscription page

+ """, + 'zh': f""" +

您的VPN订阅已准备就绪!

+
+

套餐: {tariff_name}

+

期限: {period_days} 天

+
+

您的订阅链接:

+

{subscription_url}

+

复制链接并添加到您的VPN应用中。

+

打开订阅页面

+ """, + 'ua': f""" +

Ваша VPN підписка готова!

+
+

Тариф: {tariff_name}

+

Період: {period_days} днів

+
+

Ваше посилання підписки:

+

{subscription_url}

+

Скопіюйте посилання та додайте у ваш VPN-додаток.

+

Відкрити сторінку підписки

+ """, + 'fa': f""" +

اشتراک VPN شما آماده است!

+
+

طرح: {tariff_name}

+

مدت: {period_days} روز

+
+

لینک اشتراک شما:

+

{subscription_url}

+

لینک را کپی کنید و به اپلیکیشن VPN خود اضافه کنید.

+

باز کردن صفحه اشتراک

+ """, + } + + return { + 'subject': subjects.get(language, subjects['ru']), + 'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language), + } + + def _guest_activation_required_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: + """Template for guest purchase pending activation (user already has a subscription).""" + tariff_name = html.escape(context.get('tariff_name', '')) + period_days = context.get('period_days', 0) + success_page_url = html.escape(context.get('success_page_url', '')) + gift_message = context.get('gift_message') + is_gift = context.get('is_gift', False) + + gift_block_ru = '' + gift_block_en = '' + gift_block_zh = '' + gift_block_ua = '' + gift_block_fa = '' + if is_gift and gift_message: + escaped_msg = html.escape(gift_message) + gift_block_ru = f'

Сообщение: {escaped_msg}

' + gift_block_en = f'

Message: {escaped_msg}

' + gift_block_zh = f'

留言: {escaped_msg}

' + gift_block_ua = f'

Повідомлення: {escaped_msg}

' + gift_block_fa = f'

پیام: {escaped_msg}

' + + subjects = { + 'ru': 'Требуется активация подписки', + 'en': 'Subscription activation required', + 'zh': '需要激活订阅', + 'ua': 'Потрібна активація підписки', + 'fa': 'فعال‌سازی اشتراک لازم است', + } + + bodies = { + 'ru': f""" +

Требуется активация подписки

+ {gift_block_ru} +
+

Тариф: {tariff_name}

+

Период: {period_days} дней

+
+

У вас уже есть активная подписка. Активация новой заменит текущую.

+

Активировать подписку

+ """, + 'en': f""" +

Subscription activation required

+ {gift_block_en} +
+

Plan: {tariff_name}

+

Period: {period_days} days

+
+

You already have an active subscription. Activating will replace your current one.

+

Activate subscription

+ """, + 'zh': f""" +

需要激活订阅

+ {gift_block_zh} +
+

套餐: {tariff_name}

+

期限: {period_days} 天

+
+

您已有活跃订阅。激活新订阅将替换当前订阅。

+

激活订阅

+ """, + 'ua': f""" +

Потрібна активація підписки

+ {gift_block_ua} +
+

Тариф: {tariff_name}

+

Період: {period_days} днів

+
+

У вас вже є активна підписка. Активація нової замінить поточну.

+

Активувати підписку

+ """, + 'fa': f""" +

فعال‌سازی اشتراک لازم است

+ {gift_block_fa} +
+

طرح: {tariff_name}

+

مدت: {period_days} روز

+
+

شما از قبل اشتراک فعالی دارید. فعال‌سازی اشتراک جدید جایگزین فعلی خواهد شد.

+

فعال‌سازی اشتراک

+ """, + } + + return { + 'subject': subjects.get(language, subjects['ru']), + 'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language), + } + + def _guest_gift_received_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: + """Template for gift subscription received notification.""" + subscription_url = html.escape(context.get('subscription_url', '')) + tariff_name = html.escape(context.get('tariff_name', '')) + period_days = context.get('period_days', 0) + gift_message = context.get('gift_message') + success_page_url = html.escape(context.get('success_page_url', '')) + + gift_block_ru = '' + gift_block_en = '' + gift_block_zh = '' + gift_block_ua = '' + gift_block_fa = '' + if gift_message: + escaped_msg = html.escape(gift_message) + gift_block_ru = f'

Сообщение: {escaped_msg}

' + gift_block_en = f'

Message: {escaped_msg}

' + gift_block_zh = f'

留言: {escaped_msg}

' + gift_block_ua = f'

Повідомлення: {escaped_msg}

' + gift_block_fa = f'

پیام: {escaped_msg}

' + + subjects = { + 'ru': 'Вам подарили VPN подписку!', + 'en': "You've been gifted a VPN subscription!", + 'zh': '您收到了VPN订阅礼物!', + 'ua': 'Вам подарували VPN підписку!', + 'fa': 'یک اشتراک VPN به شما هدیه داده شده است!', + } + + bodies = { + 'ru': f""" +

Вам подарили VPN подписку!

+ {gift_block_ru} +
+

Тариф: {tariff_name}

+

Период: {period_days} дней

+
+

Ваша ссылка подписки:

+

{subscription_url}

+

Скопируйте ссылку и добавьте в ваше VPN-приложение.

+

Открыть страницу подписки

+ """, + 'en': f""" +

You've been gifted a VPN subscription!

+ {gift_block_en} +
+

Plan: {tariff_name}

+

Period: {period_days} days

+
+

Your subscription link:

+

{subscription_url}

+

Copy the link and add it to your VPN app.

+

Open subscription page

+ """, + 'zh': f""" +

您收到了VPN订阅礼物!

+ {gift_block_zh} +
+

套餐: {tariff_name}

+

期限: {period_days} 天

+
+

您的订阅链接:

+

{subscription_url}

+

复制链接并添加到您的VPN应用中。

+

打开订阅页面

+ """, + 'ua': f""" +

Вам подарували VPN підписку!

+ {gift_block_ua} +
+

Тариф: {tariff_name}

+

Період: {period_days} днів

+
+

Ваше посилання підписки:

+

{subscription_url}

+

Скопіюйте посилання та додайте у ваш VPN-додаток.

+

Відкрити сторінку підписки

+ """, + 'fa': f""" +

یک اشتراک VPN به شما هدیه داده شده است!

+ {gift_block_fa} +
+

طرح: {tariff_name}

+

مدت: {period_days} روز

+
+

لینک اشتراک شما:

+

{subscription_url}

+

لینک را کپی کنید و به اپلیکیشن VPN خود اضافه کنید.

+

باز کردن صفحه اشتراک

+ """, + } + + return { + 'subject': subjects.get(language, subjects['ru']), + 'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language), + } + # Singleton instance email_notification_templates = EmailNotificationTemplates() diff --git a/app/database/models.py b/app/database/models.py index fd5ed947..0440e72e 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3023,6 +3023,7 @@ class GuestPurchaseStatus(str, Enum): PENDING = 'pending' PAID = 'paid' DELIVERED = 'delivered' + PENDING_ACTIVATION = 'pending_activation' FAILED = 'failed' EXPIRED = 'expired' diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 99161d77..11ebbdfd 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -1,14 +1,17 @@ """Service for guest (unauthenticated) purchases via landing pages.""" +import asyncio from datetime import UTC, datetime +from typing import Literal import structlog from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.crud.landing import create_guest_purchase -from app.database.crud.subscription import create_paid_subscription +from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription from app.database.crud.tariff import get_tariff_by_id from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User from app.services.subscription_service import SubscriptionService @@ -141,22 +144,47 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha try: # Determine recipient contact info - if purchase.is_gift and purchase.gift_recipient_type and purchase.gift_recipient_value: - recipient_type = purchase.gift_recipient_type - recipient_value = purchase.gift_recipient_value - else: - recipient_type = purchase.contact_type - recipient_value = purchase.contact_value + recipient_type, recipient_value = _get_recipient_contact(purchase) # Find or create user for the recipient (no commit — stays within our transaction) user = await _find_or_create_user(db, recipient_type, recipient_value) - # Create local subscription and provision in RemnaWave + # Load tariff early — needed for both PENDING_ACTIVATION and DELIVERED paths tariff = await get_tariff_by_id(db, purchase.tariff_id) if tariff is None: logger.error('Tariff not found during fulfillment', tariff_id=purchase.tariff_id) raise GuestPurchaseError('Tariff not found', status_code=500) + # Resolve notification params before any commit (avoids lazy-loading after commit) + notification_tariff_name = tariff.name + notification_language = user.language if hasattr(user, 'language') and user.language else 'ru' + + # Check if user already has an active subscription — hold for manual activation + existing_subscription = await get_subscription_by_user_id(db, user.id) + if existing_subscription is not None and existing_subscription.is_active: + purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value + purchase.user_id = user.id + await db.commit() + await db.refresh(purchase) + + try: + await send_guest_notification( + purchase, + is_pending_activation=True, + tariff_name=notification_tariff_name, + language=notification_language, + ) + except Exception: + logger.exception('Failed to send pending_activation notification', purchase_id=purchase.id) + + logger.info( + 'Guest purchase held for activation (existing subscription)', + purchase_id=purchase.id, + token_prefix=purchase_token[:5], + user_id=user.id, + ) + return purchase + # Verify the purchase amount matches the tariff price expected_price = tariff.get_price_for_period(purchase.period_days) if expected_price is not None and purchase.amount_kopeks != expected_price: @@ -195,7 +223,15 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha await db.commit() await db.refresh(purchase) - # TODO: Send delivery notification (email or Telegram) with subscription_url + try: + await send_guest_notification( + purchase, + is_pending_activation=False, + tariff_name=notification_tariff_name, + language=notification_language, + ) + except Exception: + logger.exception('Failed to send delivery notification', purchase_id=purchase.id) logger.info( 'Guest purchase fulfilled', @@ -205,6 +241,8 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha recipient_type=recipient_type, ) + except GuestPurchaseError: + raise except Exception: await db.rollback() logger.exception( @@ -212,26 +250,30 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha token_prefix=purchase_token[:5], purchase_id=purchase.id, ) - raise + raise GuestPurchaseError('Purchase fulfillment failed', status_code=500) return purchase def _mask_email(email: str) -> str: """Mask email for logging: 'user@example.com' -> 'u***@e***.com'.""" + if not email: + return '***' parts = email.split('@') if len(parts) != 2: return '***' - local = parts[0][0] + '***' if len(parts[0]) > 0 else '***' + local = parts[0][0] + '***' if parts[0] else '***' domain_parts = parts[1].split('.') - domain = domain_parts[0][0] + '***' if len(domain_parts[0]) > 0 else '***' + if not domain_parts[0]: + return f'{local}@***' + domain = domain_parts[0][0] + '***' tld = domain_parts[-1] if len(domain_parts) > 1 else '' return f'{local}@{domain}.{tld}' async def _find_or_create_user( db: AsyncSession, - contact_type: str, + contact_type: Literal['email', 'telegram'], contact_value: str, ) -> User: """Find user by email/telegram username or create a new one. @@ -295,3 +337,183 @@ async def _find_or_create_user( raise logger.info('Created new telegram user for guest purchase', user_id=user.id, username=username) return user + + +def _get_recipient_contact(purchase: GuestPurchase) -> tuple[str, str]: + """Return (contact_type, contact_value) for the purchase recipient.""" + if purchase.is_gift and purchase.gift_recipient_type and purchase.gift_recipient_value: + return purchase.gift_recipient_type, purchase.gift_recipient_value + return purchase.contact_type, purchase.contact_value + + +async def send_guest_notification( + purchase: GuestPurchase, + *, + is_pending_activation: bool = False, + tariff_name: str = '', + language: str = 'ru', +) -> None: + """Send email notification for guest purchase delivery or activation requirement. + + For telegram contacts, no notification is sent (success page only). + For gifts, notification goes to the recipient, not the buyer. + + Args: + purchase: The guest purchase record. + is_pending_activation: Whether this is a pending activation notification. + tariff_name: Pre-resolved tariff name (avoids lazy-loading after commit). + language: User language for email template (avoids lazy-loading user relationship). + """ + # Lazy imports to avoid circular dependencies (cabinet services -> services -> cabinet) + from app.cabinet.services.email_service import email_service + from app.cabinet.services.email_templates import EmailNotificationTemplates + from app.services.notification_delivery_service import NotificationType + + recipient_type, recipient_email = _get_recipient_contact(purchase) + + if recipient_type != 'email': + return + + success_page_url = f'{(settings.CABINET_URL or "").rstrip("/")}/buy/success/{purchase.token}' + + context = { + 'tariff_name': tariff_name, + 'period_days': purchase.period_days, + 'success_page_url': success_page_url, + 'subscription_url': purchase.subscription_url or '', + 'is_gift': purchase.is_gift, + 'gift_message': purchase.gift_message, + } + + if is_pending_activation: + notification_type = NotificationType.GUEST_ACTIVATION_REQUIRED + elif purchase.is_gift: + notification_type = NotificationType.GUEST_GIFT_RECEIVED + else: + notification_type = NotificationType.GUEST_SUBSCRIPTION_DELIVERED + + templates = EmailNotificationTemplates() + template = templates.get_template(notification_type, language, context) + if not template: + logger.warning('No email template found for guest notification', notification_type=notification_type.value) + return + + result = await asyncio.to_thread( + email_service.send_email, + to_email=recipient_email, + subject=template['subject'], + body_html=template['body_html'], + ) + + if result: + logger.info( + 'Guest purchase notification sent', + purchase_id=purchase.id, + notification_type=notification_type.value, + recipient_masked=_mask_email(recipient_email), + ) + else: + logger.warning( + 'Failed to send guest purchase notification', + purchase_id=purchase.id, + notification_type=notification_type.value, + ) + + +async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurchase: + """Activate a PENDING_ACTIVATION purchase by replacing or creating a subscription. + + Uses SELECT ... FOR UPDATE to prevent concurrent activation. + Raises GuestPurchaseError on validation failures. + Returns the updated purchase (status=DELIVERED). + """ + result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update()) + purchase = result.scalars().first() + + if purchase is None: + raise GuestPurchaseError('Purchase not found', status_code=404) + + # Idempotent: already delivered + if purchase.status == GuestPurchaseStatus.DELIVERED.value: + return purchase + + if purchase.status != GuestPurchaseStatus.PENDING_ACTIVATION.value: + raise GuestPurchaseError('Purchase is not pending activation', status_code=400) + + tariff = await get_tariff_by_id(db, purchase.tariff_id) + if tariff is None: + raise GuestPurchaseError('Tariff not found', status_code=500) + + if not purchase.user_id: + raise GuestPurchaseError('No user linked to purchase', status_code=500) + + user_result = await db.execute(select(User).where(User.id == purchase.user_id)) + user = user_result.scalars().first() + if user is None: + raise GuestPurchaseError('User not found', status_code=500) + + # Resolve notification params before any commit (avoids lazy-loading after commit) + notification_tariff_name = tariff.name + notification_language = user.language if hasattr(user, 'language') and user.language else 'ru' + + try: + existing_subscription = await get_subscription_by_user_id(db, user.id) + subscription_service = SubscriptionService() + + if existing_subscription is not None: + subscription = await replace_subscription( + db, + existing_subscription, + duration_days=purchase.period_days, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=tariff.allowed_squads, + is_trial=False, + ) + subscription.tariff_id = tariff.id + else: + subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=purchase.period_days, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=tariff.allowed_squads, + tariff_id=tariff.id, + ) + + await subscription_service.create_remnawave_user(db, subscription) + await db.refresh(subscription) + + purchase.subscription_url = subscription.subscription_url + purchase.subscription_crypto_link = subscription.subscription_crypto_link + purchase.status = GuestPurchaseStatus.DELIVERED.value + purchase.delivered_at = datetime.now(UTC) + await db.commit() + await db.refresh(purchase) + + try: + await send_guest_notification( + purchase, + is_pending_activation=False, + tariff_name=notification_tariff_name, + language=notification_language, + ) + except Exception: + logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id) + + logger.info( + 'Guest purchase activated', + purchase_id=purchase.id, + token_prefix=purchase_token[:5], + user_id=user.id, + ) + + except GuestPurchaseError: + raise + except Exception: + await db.rollback() + logger.exception('Failed to activate purchase', purchase_id=purchase.id) + raise GuestPurchaseError('Activation failed, please try again', status_code=500) + + return purchase diff --git a/app/services/notification_delivery_service.py b/app/services/notification_delivery_service.py index d9c5b630..f1759342 100644 --- a/app/services/notification_delivery_service.py +++ b/app/services/notification_delivery_service.py @@ -84,6 +84,11 @@ class NotificationType(Enum): BROADCAST = 'broadcast' PAYMENT_RECEIVED = 'payment_received' + # Guest purchase notifications + GUEST_SUBSCRIPTION_DELIVERED = 'guest_subscription_delivered' + GUEST_ACTIVATION_REQUIRED = 'guest_activation_required' + GUEST_GIFT_RECEIVED = 'guest_gift_received' + class NotificationDeliveryService: """ diff --git a/app/services/payment/common.py b/app/services/payment/common.py index dfcf7934..b5d30573 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -462,6 +462,7 @@ async def try_fulfill_guest_purchase( # Idempotency: skip terminal states if existing and existing.status in ( GuestPurchaseStatus.DELIVERED.value, + GuestPurchaseStatus.PENDING_ACTIVATION.value, GuestPurchaseStatus.FAILED.value, ): logger.info(