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:
@@ -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 [])
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3023,6 +3023,7 @@ class GuestPurchaseStatus(str, Enum):
|
||||
PENDING = 'pending'
|
||||
PAID = 'paid'
|
||||
DELIVERED = 'delivered'
|
||||
PENDING_ACTIVATION = 'pending_activation'
|
||||
FAILED = 'failed'
|
||||
EXPIRED = 'expired'
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user