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
This commit is contained in:
Fringg
2026-03-06 19:56:37 +03:00
parent b85646af85
commit 776fc3aadc
8 changed files with 660 additions and 47 deletions
+52 -7
View File
@@ -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 [])
]
+93 -27
View File
@@ -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(
+4
View File
@@ -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
+269
View File
@@ -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"""
<h2>Ваша VPN подписка готова!</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p>Ваша ссылка подписки:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>Скопируйте ссылку и добавьте в ваше VPN-приложение.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Открыть страницу подписки</a></p>
""",
'en': f"""
<h2>Your VPN subscription is ready!</h2>
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p>Your subscription link:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>Copy the link and add it to your VPN app.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Open subscription page</a></p>
""",
'zh': f"""
<h2>您的VPN订阅已准备就绪!</h2>
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} 天</strong></p>
</div>
<p>您的订阅链接:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>复制链接并添加到您的VPN应用中。</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">打开订阅页面</a></p>
""",
'ua': f"""
<h2>Ваша VPN підписка готова!</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p>Ваше посилання підписки:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>Скопіюйте посилання та додайте у ваш VPN-додаток.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Відкрити сторінку підписки</a></p>
""",
'fa': f"""
<h2>اشتراک VPN شما آماده است!</h2>
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p>لینک اشتراک شما:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>لینک را کپی کنید و به اپلیکیشن VPN خود اضافه کنید.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">باز کردن صفحه اشتراک</a></p>
""",
}
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'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
subjects = {
'ru': 'Требуется активация подписки',
'en': 'Subscription activation required',
'zh': '需要激活订阅',
'ua': 'Потрібна активація підписки',
'fa': 'فعال‌سازی اشتراک لازم است',
}
bodies = {
'ru': f"""
<h2>Требуется активация подписки</h2>
{gift_block_ru}
<div class="highlight">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p class="warning">У вас уже есть активная подписка. Активация новой заменит текущую.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активировать подписку</a></p>
""",
'en': f"""
<h2>Subscription activation required</h2>
{gift_block_en}
<div class="highlight">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p class="warning">You already have an active subscription. Activating will replace your current one.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Activate subscription</a></p>
""",
'zh': f"""
<h2>需要激活订阅</h2>
{gift_block_zh}
<div class="highlight">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} 天</strong></p>
</div>
<p class="warning">您已有活跃订阅。激活新订阅将替换当前订阅。</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">激活订阅</a></p>
""",
'ua': f"""
<h2>Потрібна активація підписки</h2>
{gift_block_ua}
<div class="highlight">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p class="warning">У вас вже є активна підписка. Активація нової замінить поточну.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активувати підписку</a></p>
""",
'fa': f"""
<h2>فعال‌سازی اشتراک لازم است</h2>
{gift_block_fa}
<div class="highlight">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p class="warning">شما از قبل اشتراک فعالی دارید. فعال‌سازی اشتراک جدید جایگزین فعلی خواهد شد.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">فعال‌سازی اشتراک</a></p>
""",
}
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'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
subjects = {
'ru': 'Вам подарили VPN подписку!',
'en': "You've been gifted a VPN subscription!",
'zh': '您收到了VPN订阅礼物!',
'ua': 'Вам подарували VPN підписку!',
'fa': 'یک اشتراک VPN به شما هدیه داده شده است!',
}
bodies = {
'ru': f"""
<h2>Вам подарили VPN подписку!</h2>
{gift_block_ru}
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p>Ваша ссылка подписки:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>Скопируйте ссылку и добавьте в ваше VPN-приложение.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Открыть страницу подписки</a></p>
""",
'en': f"""
<h2>You've been gifted a VPN subscription!</h2>
{gift_block_en}
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p>Your subscription link:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>Copy the link and add it to your VPN app.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Open subscription page</a></p>
""",
'zh': f"""
<h2>您收到了VPN订阅礼物!</h2>
{gift_block_zh}
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} 天</strong></p>
</div>
<p>您的订阅链接:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>复制链接并添加到您的VPN应用中。</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">打开订阅页面</a></p>
""",
'ua': f"""
<h2>Вам подарували VPN підписку!</h2>
{gift_block_ua}
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p>Ваше посилання підписки:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>Скопіюйте посилання та додайте у ваш VPN-додаток.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Відкрити сторінку підписки</a></p>
""",
'fa': f"""
<h2>یک اشتراک VPN به شما هدیه داده شده است!</h2>
{gift_block_fa}
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p>لینک اشتراک شما:</p>
<p style="word-break: break-all;"><a href="{subscription_url}">{subscription_url}</a></p>
<p>لینک را کپی کنید و به اپلیکیشن VPN خود اضافه کنید.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">باز کردن صفحه اشتراک</a></p>
""",
}
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()
+1
View File
@@ -3023,6 +3023,7 @@ class GuestPurchaseStatus(str, Enum):
PENDING = 'pending'
PAID = 'paid'
DELIVERED = 'delivered'
PENDING_ACTIVATION = 'pending_activation'
FAILED = 'failed'
EXPIRED = 'expired'
+235 -13
View File
@@ -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
@@ -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:
"""
+1
View File
@@ -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(