Merge pull request #5 from FireWookie/dev

Dev
This commit is contained in:
FireWookie
2026-03-10 14:18:49 +05:00
committed by GitHub
9 changed files with 513 additions and 57 deletions
+266 -44
View File
@@ -8,9 +8,9 @@ import structlog
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings from app.config import settings
from app.database.crud.landing import get_purchase_by_token
from app.database.crud.system_setting import get_setting_value from app.database.crud.system_setting import get_setting_value
from app.database.crud.tariff import get_tariff_by_id from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
@@ -26,6 +26,8 @@ from app.utils.cache import RateLimitCache
from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.gift import ( from ..schemas.gift import (
ActivateGiftRequest,
ActivateGiftResponse,
GiftConfigPaymentMethod, GiftConfigPaymentMethod,
GiftConfigResponse, GiftConfigResponse,
GiftConfigSubOption, GiftConfigSubOption,
@@ -35,6 +37,8 @@ from ..schemas.gift import (
GiftPurchaseResponse, GiftPurchaseResponse,
GiftPurchaseStatusResponse, GiftPurchaseStatusResponse,
PendingGiftResponse, PendingGiftResponse,
ReceivedGiftResponse,
SentGiftResponse,
) )
@@ -156,33 +160,37 @@ async def create_gift_purchase(
detail='Purchases are restricted for this account', detail='Purchases are restricted for this account',
) )
# Validate recipient format # Recipient is optional — when omitted, buyer gets a code to share manually
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value): has_recipient = bool(body.recipient_type and body.recipient_value)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid email format',
)
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid Telegram username format',
)
# Prevent self-gift if has_recipient:
if body.recipient_type == 'telegram': # Validate recipient format
normalized_recipient = body.recipient_value.lstrip('@').lower() if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
if user.username and user.username.lower() == normalized_recipient:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself', detail='Invalid email format',
) )
elif body.recipient_type == 'email': if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
if user.email and user.email.lower() == body.recipient_value.lower():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself', detail='Invalid Telegram username format',
) )
# Prevent self-gift
if body.recipient_type == 'telegram':
normalized_recipient = body.recipient_value.lstrip('@').lower()
if user.username and user.username.lower() == normalized_recipient:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
elif body.recipient_type == 'email':
if user.email and user.email.lower() == body.recipient_value.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
# Find tariff and validate period # Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id) tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active: if tariff is None or not tariff.is_active:
@@ -210,11 +218,10 @@ async def create_gift_purchase(
buyer_contact_value = f'id:{user.telegram_id or user.id}' buyer_contact_value = f'id:{user.telegram_id or user.id}'
# Pre-check: try to resolve Telegram username — DB first, then Bot API. # Pre-check: try to resolve Telegram username — DB first, then Bot API.
# Placed after validation gates to prevent zero-cost enumeration. # Only relevant when a recipient is explicitly specified.
# The resolved ID is passed to fulfill_purchase to avoid a duplicate API call.
recipient_warning: str | None = None recipient_warning: str | None = None
pre_resolved_telegram_id: int | None = None pre_resolved_telegram_id: int | None = None
if body.recipient_type == 'telegram': if has_recipient and body.recipient_type == 'telegram':
tg_username = body.recipient_value.lstrip('@') tg_username = body.recipient_value.lstrip('@')
normalized_username = tg_username.lower() normalized_username = tg_username.lower()
@@ -253,6 +260,14 @@ async def create_gift_purchase(
detail='payment_method is required for gateway mode', detail='payment_method is required for gateway mode',
) )
purchase_kwargs: dict = {
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
} if has_recipient else {
'gift_message': body.gift_message,
}
try: try:
purchase = await create_purchase( purchase = await create_purchase(
db, db,
@@ -264,12 +279,10 @@ async def create_gift_purchase(
contact_value=buyer_contact_value, contact_value=buyer_contact_value,
payment_method=body.payment_method, payment_method=body.payment_method,
is_gift=True, is_gift=True,
gift_recipient_type=body.recipient_type,
gift_recipient_value=body.recipient_value,
gift_message=body.gift_message,
source='cabinet', source='cabinet',
buyer_user_id=user.id, buyer_user_id=user.id,
commit=False, commit=False,
**purchase_kwargs,
) )
except GuestPurchaseError as exc: except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
@@ -280,7 +293,7 @@ async def create_gift_purchase(
# Build return URL for after payment # Build return URL for after payment
cabinet_base = (settings.CABINET_URL or '').rstrip('/') cabinet_base = (settings.CABINET_URL or '').rstrip('/')
return_url = f'{cabinet_base}/gift/result?token={purchase.token}' return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
from app.services.payment_service import PaymentService from app.services.payment_service import PaymentService
@@ -319,7 +332,7 @@ async def create_gift_purchase(
return GiftPurchaseResponse( return GiftPurchaseResponse(
status='created', status='created',
purchase_token=purchase.token, purchase_token=purchase.token[:12],
payment_url=payment_url, payment_url=payment_url,
warning=recipient_warning, warning=recipient_warning,
) )
@@ -332,6 +345,14 @@ async def create_gift_purchase(
) )
# Create purchase record # Create purchase record
balance_purchase_kwargs: dict = {
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
} if has_recipient else {
'gift_message': body.gift_message,
}
try: try:
purchase = await create_purchase( purchase = await create_purchase(
db, db,
@@ -343,12 +364,10 @@ async def create_gift_purchase(
contact_value=buyer_contact_value, contact_value=buyer_contact_value,
payment_method='balance', payment_method='balance',
is_gift=True, is_gift=True,
gift_recipient_type=body.recipient_type,
gift_recipient_value=body.recipient_value,
gift_message=body.gift_message,
source='cabinet', source='cabinet',
buyer_user_id=user.id, buyer_user_id=user.id,
commit=False, commit=False,
**balance_purchase_kwargs,
) )
except GuestPurchaseError as exc: except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
@@ -372,13 +391,18 @@ async def create_gift_purchase(
detail='Insufficient balance', detail='Insufficient balance',
) )
# Transaction description: include recipient when specified
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
if has_recipient:
tx_description += f' -> {body.recipient_value}'
# Create transaction record # Create transaction record
transaction = await create_transaction( transaction = await create_transaction(
db, db,
user_id=user.id, user_id=user.id,
type=TransactionType.GIFT_PAYMENT, type=TransactionType.GIFT_PAYMENT,
amount_kopeks=price_kopeks, amount_kopeks=price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}', description=tx_description,
payment_method=PaymentMethod.BALANCE, payment_method=PaymentMethod.BALANCE,
commit=False, commit=False,
) )
@@ -397,24 +421,26 @@ async def create_gift_purchase(
user_id=user.id, user_id=user.id,
type=TransactionType.GIFT_PAYMENT, type=TransactionType.GIFT_PAYMENT,
payment_method=PaymentMethod.BALANCE, payment_method=PaymentMethod.BALANCE,
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}', description=tx_description,
) )
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill # Capture token before fulfill_purchase — session state may change after rollback inside fulfill
purchase_token = purchase.token purchase_token = purchase.token
# Fulfill the purchase (find/create recipient user, create subscription, notify) # Only fulfill immediately when a specific recipient was provided.
try: # Code-only gifts (no recipient) stay in PAID status until someone activates via code.
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id) if has_recipient:
except Exception: try:
logger.exception( await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
'Gift purchase fulfillment failed (purchase is paid, will retry)', except Exception:
purchase_id=purchase.id, logger.exception(
) 'Gift purchase fulfillment failed (purchase is paid, will retry)',
purchase_id=purchase.id,
)
return GiftPurchaseResponse( return GiftPurchaseResponse(
status='ok', status='ok',
purchase_token=purchase_token, purchase_token=purchase_token[:12],
warning=recipient_warning, warning=recipient_warning,
) )
@@ -427,12 +453,14 @@ async def get_pending_gifts(
"""Get pending gift purchases that the current user can activate.""" """Get pending gift purchases that the current user can activate."""
result = await db.execute( result = await db.execute(
select(GuestPurchase) select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where( .where(
GuestPurchase.user_id == user.id, GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True), GuestPurchase.is_gift.is_(True),
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value, GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
) )
.order_by(GuestPurchase.created_at.desc()) .order_by(GuestPurchase.created_at.desc())
.limit(100)
) )
purchases = result.scalars().all() purchases = result.scalars().all()
@@ -445,7 +473,7 @@ async def get_pending_gifts(
pending.append( pending.append(
PendingGiftResponse( PendingGiftResponse(
token=p.token, token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None, tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days, period_days=p.period_days,
gift_message=p.gift_message, gift_message=p.gift_message,
@@ -464,7 +492,17 @@ async def get_gift_purchase_status(
db: AsyncSession = Depends(get_cabinet_db), db: AsyncSession = Depends(get_cabinet_db),
): ):
"""Get the status of a cabinet gift purchase.""" """Get the status of a cabinet gift purchase."""
purchase = await get_purchase_by_token(db, token) if len(token) >= 64:
token_filter = GuestPurchase.token == token
else:
token_filter = GuestPurchase.token.startswith(token)
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter)
)
purchase = result.scalars().first()
if purchase is None: if purchase is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -484,12 +522,196 @@ async def get_gift_purchase_status(
if purchase.gift_recipient_value: if purchase.gift_recipient_value:
recipient_contact_value = purchase.gift_recipient_value recipient_contact_value = purchase.gift_recipient_value
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
return GiftPurchaseStatusResponse( return GiftPurchaseStatusResponse(
status=purchase.status, status=purchase.status,
is_gift=True, is_gift=True,
is_code_only=is_code_only,
purchase_token=purchase.token[:12] if is_code_only else None,
recipient_contact_value=recipient_contact_value, recipient_contact_value=recipient_contact_value,
gift_message=purchase.gift_message, gift_message=purchase.gift_message,
tariff_name=tariff_name, tariff_name=tariff_name,
period_days=purchase.period_days, period_days=purchase.period_days,
warning=purchase.recipient_warning, warning=purchase.recipient_warning,
) )
@router.get('/sent', response_model=list[SentGiftResponse])
async def get_sent_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has sent."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
.where(
GuestPurchase.buyer_user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
sent: list[SentGiftResponse] = []
for p in purchases:
activated_by_username = None
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
activated_by_username = f'@{p.user.username}'
sent.append(
SentGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
gift_recipient_value=p.gift_recipient_value,
gift_message=p.gift_message,
activated_by_username=activated_by_username,
created_at=p.created_at,
)
)
return sent
@router.get('/received', response_model=list[ReceivedGiftResponse])
async def get_received_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has received."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
received: list[ReceivedGiftResponse] = []
for p in purchases:
sender_display = None
if p.buyer and p.buyer.username:
sender_display = f'@{p.buyer.username}'
elif p.contact_value:
sender_display = p.contact_value
received.append(
ReceivedGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
sender_display=sender_display,
gift_message=p.gift_message,
created_at=p.created_at,
)
)
return received
@router.post('/activate', response_model=ActivateGiftResponse)
async def activate_gift_by_code(
body: ActivateGiftRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a gift subscription by its code (token)."""
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
code = body.code.strip()
if code.upper().startswith('GIFT-'):
code = code[5:]
if len(code) < 8:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
# Support both full token and prefix-based lookup (displayed codes are truncated)
if len(code) >= 64:
# Full token — exact match
token_filter = GuestPurchase.token == code
else:
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
token_filter = GuestPurchase.token.startswith(code)
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
purchase = result.scalars().first()
if purchase is None or not purchase.is_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
if purchase.user_id is not None and purchase.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Prevent self-activation: buyer cannot activate their own gift
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot activate your own gift',
)
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
activatable_statuses = {
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
}
if purchase.status not in activatable_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This gift cannot be activated',
)
# For code-only gifts (user_id is None), link the purchase to the activating user
if purchase.user_id is None:
purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if purchase.status == GuestPurchaseStatus.PAID.value:
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
try:
await svc_activate(db, purchase.token, skip_notification=True)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
+41 -2
View File
@@ -50,8 +50,8 @@ class GiftConfigResponse(BaseModel):
class GiftPurchaseRequest(BaseModel): class GiftPurchaseRequest(BaseModel):
tariff_id: int = Field(gt=0) tariff_id: int = Field(gt=0)
period_days: int = Field(gt=0, le=3650) period_days: int = Field(gt=0, le=3650)
recipient_type: str = Field(pattern=r'^(email|telegram)$') recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
recipient_value: str = Field(min_length=1, max_length=255) recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000) gift_message: str | None = Field(default=None, max_length=1000)
payment_mode: str = Field(pattern=r'^(balance|gateway)$') payment_mode: str = Field(pattern=r'^(balance|gateway)$')
payment_method: str | None = Field(default=None, max_length=50) payment_method: str | None = Field(default=None, max_length=50)
@@ -73,6 +73,8 @@ class GiftPurchaseResponse(BaseModel):
class GiftPurchaseStatusResponse(BaseModel): class GiftPurchaseStatusResponse(BaseModel):
status: str status: str
is_gift: bool = True is_gift: bool = True
is_code_only: bool = False
purchase_token: str | None = None
recipient_contact_value: str | None = None recipient_contact_value: str | None = None
gift_message: str | None = None gift_message: str | None = None
tariff_name: str | None = None tariff_name: str | None = None
@@ -87,3 +89,40 @@ class PendingGiftResponse(BaseModel):
gift_message: str | None = None gift_message: str | None = None
sender_display: str | None = None sender_display: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
class SentGiftResponse(BaseModel):
"""A gift the current user has sent."""
token: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
status: str
gift_recipient_value: str | None = None
gift_message: str | None = None
activated_by_username: str | None = None
created_at: datetime | None = None
class ReceivedGiftResponse(BaseModel):
"""A gift the current user has received."""
token: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
status: str
sender_display: str | None = None
gift_message: str | None = None
created_at: datetime | None = None
class ActivateGiftRequest(BaseModel):
code: str = Field(min_length=1, max_length=100)
class ActivateGiftResponse(BaseModel):
status: str
tariff_name: str | None = None
period_days: int | None = None
+3 -2
View File
@@ -48,9 +48,10 @@ async def record_notification(
await db.commit() await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None: async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id)) await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
await db.commit() if commit:
await db.commit()
async def clear_notification_by_type( async def clear_notification_by_type(
+13 -5
View File
@@ -190,6 +190,7 @@ async def create_paid_subscription(
update_server_counters: bool = False, update_server_counters: bool = False,
is_trial: bool = False, is_trial: bool = False,
tariff_id: int | None = None, tariff_id: int | None = None,
commit: bool = True,
) -> Subscription: ) -> Subscription:
end_date = datetime.now(UTC) + timedelta(days=duration_days) end_date = datetime.now(UTC) + timedelta(days=duration_days)
@@ -211,8 +212,11 @@ async def create_paid_subscription(
) )
db.add(subscription) db.add(subscription)
await db.commit() if commit:
await db.refresh(subscription) await db.commit()
await db.refresh(subscription)
else:
await db.flush()
logger.info( logger.info(
'💎 Создана платная подписка для пользователя ID: статус', '💎 Создана платная подписка для пользователя ID: статус',
@@ -265,6 +269,7 @@ async def replace_subscription(
autopay_enabled: bool | None = None, autopay_enabled: bool | None = None,
autopay_days_before: int | None = None, autopay_days_before: int | None = None,
update_server_counters: bool = False, update_server_counters: bool = False,
commit: bool = True,
) -> Subscription: ) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя.""" """Перезаписывает параметры существующей подписки пользователя."""
@@ -297,12 +302,15 @@ async def replace_subscription(
subscription.autopay_days_before = new_autopay_days_before subscription.autopay_days_before = new_autopay_days_before
subscription.updated_at = current_time subscription.updated_at = current_time
await db.commit() if commit:
await db.refresh(subscription) await db.commit()
await db.refresh(subscription)
else:
await db.flush()
# Очищаем старые записи об отправленных уведомлениях при замене подписки # Очищаем старые записи об отправленных уведомлениях при замене подписки
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно # (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
await clear_notifications(db, subscription.id) await clear_notifications(db, subscription.id, commit=commit)
if update_server_counters: if update_server_counters:
try: try:
+98 -1
View File
@@ -1,7 +1,10 @@
from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
import structlog import structlog
from aiogram import Bot, Dispatcher, F, types from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
@@ -21,7 +24,7 @@ from app.database.crud.user import (
get_user_by_telegram_id, get_user_by_telegram_id,
) )
from app.database.crud.user_message import get_random_active_message from app.database.crud.user_message import get_random_active_message
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus from app.database.models import GuestPurchase, GuestPurchaseStatus, PinnedMessage, SubscriptionStatus, UserStatus
from app.keyboards.inline import ( from app.keyboards.inline import (
get_back_keyboard, get_back_keyboard,
get_language_selection_keyboard, get_language_selection_keyboard,
@@ -60,6 +63,73 @@ from app.utils.user_utils import generate_unique_referral_code
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
async def _activate_pending_gift_after_registration(
db: AsyncSession,
state: FSMContext,
user: 'User',
answer_func: Callable[..., Any],
) -> None:
"""Extract pending_gift_token from FSM state and activate it for the newly registered user.
Must be called BEFORE state.clear() to preserve the token.
"""
gift_token: str | None = None
try:
fresh_state = await state.get_data()
gift_token = fresh_state.get('pending_gift_token')
if not gift_token:
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Support both full token and prefix-based lookup (Telegram truncates long start params)
if len(gift_token) >= 64:
token_filter = GuestPurchase.token == gift_token
else:
token_filter = GuestPurchase.token.startswith(gift_token)
gift_result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
gift_purchase = gift_result.scalars().first()
if (
gift_purchase
and gift_purchase.is_gift
and gift_purchase.status
in (
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
)
and (gift_purchase.user_id is None or gift_purchase.user_id == user.id)
and gift_purchase.buyer_user_id != user.id # prevent self-activation
):
if gift_purchase.user_id is None:
gift_purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if gift_purchase.status == GuestPurchaseStatus.PAID.value:
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
await svc_activate(db, gift_purchase.token, skip_notification=True)
tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else ''
await answer_func(
f'🎁 <b>Подарок активирован!</b>\n'
f'{tariff_name}{gift_purchase.period_days} дн.\n\n'
f'Ваша подписка обновлена.',
parse_mode=ParseMode.HTML,
)
except Exception:
logger.exception(
'Failed to auto-activate gift after registration',
token_prefix=(gift_token or '')[:5],
)
async def _claim_phantom_user( async def _claim_phantom_user(
db: AsyncSession, db: AsyncSession,
phantom: 'User', phantom: 'User',
@@ -446,6 +516,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if state_needs_update: if state_needs_update:
await state.set_data(data) await state.set_data(data)
# Handle gift code deep links: /start GIFT_{token}
if start_parameter and start_parameter.startswith('GIFT_'):
gift_token = start_parameter[5:] # Strip "GIFT_" prefix
if len(gift_token) >= 8:
logger.info(
'Gift code deep link detected',
token_prefix=gift_token[:5],
telegram_id=message.from_user.id,
)
# For new users, gift is auto-activated via
# _activate_pending_gift_after_registration() before state.clear().
await state.update_data(pending_gift_token=gift_token)
start_parameter = None # Don't treat as campaign or referral
if start_parameter: if start_parameter:
campaign = await get_campaign_by_start_parameter( campaign = await get_campaign_by_start_parameter(
db, db,
@@ -553,6 +637,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
except Exception as e: except Exception as e:
logger.error('Ошибка отправки уведомления о рекламной кампании', error=e) logger.error('Ошибка отправки уведомления о рекламной кампании', error=e)
# Auto-activate pending gift if deep link contained GIFT_
if user:
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.update_data(pending_gift_token=None)
# Refresh user to pick up newly created subscription
await db.refresh(user, attribute_names=['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
pinned_message = await get_active_pinned_message(db) pinned_message = await get_active_pinned_message(db)
@@ -1364,6 +1455,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
telegram_id=user.telegram_id, telegram_id=user.telegram_id,
) )
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, callback.message.answer)
await state.clear() await state.clear()
if campaign_message: if campaign_message:
@@ -1682,6 +1776,9 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
'🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id '🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id
) )
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.clear() await state.clear()
if campaign_message: if campaign_message:
+52 -2
View File
@@ -831,10 +831,9 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
connected_squads=tariff.allowed_squads or [], connected_squads=tariff.allowed_squads or [],
is_trial=False, is_trial=False,
update_server_counters=True, update_server_counters=True,
commit=False,
) )
subscription.tariff_id = tariff.id subscription.tariff_id = tariff.id
await db.commit()
await db.refresh(subscription, ['tariff'])
else: else:
subscription = await create_paid_subscription( subscription = await create_paid_subscription(
db=db, db=db,
@@ -845,6 +844,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
connected_squads=tariff.allowed_squads or [], connected_squads=tariff.allowed_squads or [],
tariff_id=tariff.id, tariff_id=tariff.id,
update_server_counters=True, update_server_counters=True,
commit=False,
) )
await subscription_service.create_remnawave_user(db, subscription) await subscription_service.create_remnawave_user(db, subscription)
@@ -856,6 +856,8 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
purchase.delivered_at = datetime.now(UTC) purchase.delivered_at = datetime.now(UTC)
if user.auth_type == 'email' and not purchase.is_gift: if user.auth_type == 'email' and not purchase.is_gift:
purchase.auto_login_token = create_auto_login_token(user.id) purchase.auto_login_token = create_auto_login_token(user.id)
# Single atomic commit: subscription + purchase status + user changes
await db.commit() await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user']) await db.refresh(purchase, attribute_names=['landing', 'user'])
@@ -923,6 +925,8 @@ async def retry_stuck_paid_purchases(
GuestPurchase.status == GuestPurchaseStatus.PAID.value, GuestPurchase.status == GuestPurchaseStatus.PAID.value,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)), or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)), or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
# Exclude code-only gifts — they stay PAID intentionally until activated
~(GuestPurchase.is_gift.is_(True) & GuestPurchase.gift_recipient_type.is_(None)),
) )
.order_by(GuestPurchase.paid_at.asc().nulls_first()) .order_by(GuestPurchase.paid_at.asc().nulls_first())
.limit(limit) .limit(limit)
@@ -943,3 +947,49 @@ async def retry_stuck_paid_purchases(
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5]) logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
return retried return retried
async def retry_stuck_pending_activation(
db: AsyncSession,
stale_minutes: int = 10,
limit: int = 10,
max_age_hours: int = 24,
) -> int:
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
This handles the case where activate_purchase() failed after the status
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
temporarily down). Each retry runs in an isolated session.
"""
from app.database.database import AsyncSessionLocal
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
result = await db.execute(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
GuestPurchase.user_id.isnot(None),
)
.order_by(GuestPurchase.paid_at.asc().nulls_first())
.limit(limit)
)
tokens = result.scalars().all()
if not tokens:
return 0
retried = 0
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await activate_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
except Exception:
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
return retried
+5 -1
View File
@@ -1682,11 +1682,15 @@ class MonitoringService:
async def _retry_stuck_guest_purchases(self, db: AsyncSession): async def _retry_stuck_guest_purchases(self, db: AsyncSession):
try: try:
from app.services.guest_purchase_service import retry_stuck_paid_purchases from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10) retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
if retried: if retried:
logger.info('Retried stuck guest purchases', retried=retried) logger.info('Retried stuck guest purchases', retried=retried)
retried_pa = await retry_stuck_pending_activation(db, stale_minutes=10, limit=10)
if retried_pa:
logger.info('Retried stuck pending_activation purchases', retried=retried_pa)
except Exception: except Exception:
logger.error('Error retrying stuck guest purchases', exc_info=True) logger.error('Error retrying stuck guest purchases', exc_info=True)
+11
View File
@@ -521,6 +521,17 @@ async def try_fulfill_guest_purchase(
paid_at=datetime.now(UTC), paid_at=datetime.now(UTC),
) )
# Code-only gifts (is_gift=True, no recipient) stay in PAID status
# — buyer shares the code manually, recipient activates via cabinet/bot
if existing and existing.is_gift and not existing.gift_recipient_type:
await db.commit()
logger.info(
'Code-only gift marked as PAID, skipping fulfillment',
purchase_token_prefix=purchase_token[:5],
provider=provider_name,
)
return True
# Fulfill: create user, subscription, deliver (commits on success) # Fulfill: create user, subscription, deliver (commits on success)
await fulfill_purchase(db, purchase_token) await fulfill_purchase(db, purchase_token)
@@ -0,0 +1,24 @@
"""Add varchar_pattern_ops index on guest_purchases.token for prefix queries.
Revision ID: 0035
Revises: 0034
"""
from alembic import op
revision = '0035'
down_revision = '0034'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
'CREATE INDEX IF NOT EXISTS ix_guest_purchases_token_pattern '
'ON guest_purchases (token varchar_pattern_ops)'
)
def downgrade() -> None:
op.execute('DROP INDEX IF EXISTS ix_guest_purchases_token_pattern')