diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index dfe78a56..6ed3cb1c 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -8,9 +8,9 @@ import structlog from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload 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.tariff import get_tariff_by_id 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 ..schemas.gift import ( + ActivateGiftRequest, + ActivateGiftResponse, GiftConfigPaymentMethod, GiftConfigResponse, GiftConfigSubOption, @@ -35,6 +37,8 @@ from ..schemas.gift import ( GiftPurchaseResponse, GiftPurchaseStatusResponse, PendingGiftResponse, + ReceivedGiftResponse, + SentGiftResponse, ) @@ -156,33 +160,37 @@ async def create_gift_purchase( detail='Purchases are restricted for this account', ) - # Validate recipient format - if body.recipient_type == 'email' and not _EMAIL_RE.match(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', - ) + # Recipient is optional — when omitted, buyer gets a code to share manually + has_recipient = bool(body.recipient_type and body.recipient_value) - # Prevent self-gift - if body.recipient_type == 'telegram': - normalized_recipient = body.recipient_value.lstrip('@').lower() - if user.username and user.username.lower() == normalized_recipient: + if has_recipient: + # Validate recipient format + if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='Cannot gift to yourself', + detail='Invalid email format', ) - elif body.recipient_type == 'email': - if user.email and user.email.lower() == body.recipient_value.lower(): + if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value): raise HTTPException( 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 tariff = await get_tariff_by_id(db, body.tariff_id) 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}' # Pre-check: try to resolve Telegram username — DB first, then Bot API. - # Placed after validation gates to prevent zero-cost enumeration. - # The resolved ID is passed to fulfill_purchase to avoid a duplicate API call. + # Only relevant when a recipient is explicitly specified. recipient_warning: str | 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('@') normalized_username = tg_username.lower() @@ -253,6 +260,14 @@ async def create_gift_purchase( 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: purchase = await create_purchase( db, @@ -264,12 +279,10 @@ async def create_gift_purchase( contact_value=buyer_contact_value, payment_method=body.payment_method, is_gift=True, - gift_recipient_type=body.recipient_type, - gift_recipient_value=body.recipient_value, - gift_message=body.gift_message, source='cabinet', buyer_user_id=user.id, commit=False, + **purchase_kwargs, ) except GuestPurchaseError as 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 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 @@ -319,7 +332,7 @@ async def create_gift_purchase( return GiftPurchaseResponse( status='created', - purchase_token=purchase.token, + purchase_token=purchase.token[:12], payment_url=payment_url, warning=recipient_warning, ) @@ -332,6 +345,14 @@ async def create_gift_purchase( ) # 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: purchase = await create_purchase( db, @@ -343,12 +364,10 @@ async def create_gift_purchase( contact_value=buyer_contact_value, payment_method='balance', is_gift=True, - gift_recipient_type=body.recipient_type, - gift_recipient_value=body.recipient_value, - gift_message=body.gift_message, source='cabinet', buyer_user_id=user.id, commit=False, + **balance_purchase_kwargs, ) except GuestPurchaseError as 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', ) + # 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 transaction = await create_transaction( db, user_id=user.id, type=TransactionType.GIFT_PAYMENT, amount_kopeks=price_kopeks, - description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}', + description=tx_description, payment_method=PaymentMethod.BALANCE, commit=False, ) @@ -397,24 +421,26 @@ async def create_gift_purchase( user_id=user.id, type=TransactionType.GIFT_PAYMENT, 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 purchase_token = purchase.token - # Fulfill the purchase (find/create recipient user, create subscription, notify) - try: - await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id) - except Exception: - logger.exception( - 'Gift purchase fulfillment failed (purchase is paid, will retry)', - purchase_id=purchase.id, - ) + # Only fulfill immediately when a specific recipient was provided. + # Code-only gifts (no recipient) stay in PAID status until someone activates via code. + if has_recipient: + try: + await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id) + except Exception: + logger.exception( + 'Gift purchase fulfillment failed (purchase is paid, will retry)', + purchase_id=purchase.id, + ) return GiftPurchaseResponse( status='ok', - purchase_token=purchase_token, + purchase_token=purchase_token[:12], warning=recipient_warning, ) @@ -427,12 +453,14 @@ async def get_pending_gifts( """Get pending gift purchases that the current user can activate.""" result = await db.execute( select(GuestPurchase) + .options(selectinload(GuestPurchase.tariff)) .where( GuestPurchase.user_id == user.id, GuestPurchase.is_gift.is_(True), GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value, ) .order_by(GuestPurchase.created_at.desc()) + .limit(100) ) purchases = result.scalars().all() @@ -445,7 +473,7 @@ async def get_pending_gifts( pending.append( PendingGiftResponse( - token=p.token, + token=p.token[:12], tariff_name=p.tariff.name if p.tariff else None, period_days=p.period_days, gift_message=p.gift_message, @@ -464,7 +492,17 @@ async def get_gift_purchase_status( db: AsyncSession = Depends(get_cabinet_db), ): """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: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -484,12 +522,196 @@ async def get_gift_purchase_status( if 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( status=purchase.status, 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, gift_message=purchase.gift_message, tariff_name=tariff_name, period_days=purchase.period_days, 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, + ) diff --git a/app/cabinet/schemas/gift.py b/app/cabinet/schemas/gift.py index 6c563b2c..860cb907 100644 --- a/app/cabinet/schemas/gift.py +++ b/app/cabinet/schemas/gift.py @@ -50,8 +50,8 @@ class GiftConfigResponse(BaseModel): class GiftPurchaseRequest(BaseModel): tariff_id: int = Field(gt=0) period_days: int = Field(gt=0, le=3650) - recipient_type: str = Field(pattern=r'^(email|telegram)$') - recipient_value: str = Field(min_length=1, max_length=255) + recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$') + recipient_value: str | None = Field(default=None, max_length=255) gift_message: str | None = Field(default=None, max_length=1000) payment_mode: str = Field(pattern=r'^(balance|gateway)$') payment_method: str | None = Field(default=None, max_length=50) @@ -73,6 +73,8 @@ class GiftPurchaseResponse(BaseModel): class GiftPurchaseStatusResponse(BaseModel): status: str is_gift: bool = True + is_code_only: bool = False + purchase_token: str | None = None recipient_contact_value: str | None = None gift_message: str | None = None tariff_name: str | None = None @@ -87,3 +89,40 @@ class PendingGiftResponse(BaseModel): gift_message: str | None = None sender_display: str | 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 diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py index bc369b21..3029112c 100644 --- a/app/database/crud/notification.py +++ b/app/database/crud/notification.py @@ -48,9 +48,10 @@ async def record_notification( 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.commit() + if commit: + await db.commit() async def clear_notification_by_type( diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index ec3160f6..7542de5f 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -190,6 +190,7 @@ async def create_paid_subscription( update_server_counters: bool = False, is_trial: bool = False, tariff_id: int | None = None, + commit: bool = True, ) -> Subscription: end_date = datetime.now(UTC) + timedelta(days=duration_days) @@ -211,8 +212,11 @@ async def create_paid_subscription( ) db.add(subscription) - await db.commit() - await db.refresh(subscription) + if commit: + await db.commit() + await db.refresh(subscription) + else: + await db.flush() logger.info( '💎 Создана платная подписка для пользователя ID: статус', @@ -265,6 +269,7 @@ async def replace_subscription( autopay_enabled: bool | None = None, autopay_days_before: int | None = None, update_server_counters: bool = False, + commit: bool = True, ) -> Subscription: """Перезаписывает параметры существующей подписки пользователя.""" @@ -297,12 +302,15 @@ async def replace_subscription( subscription.autopay_days_before = new_autopay_days_before subscription.updated_at = current_time - await db.commit() - await db.refresh(subscription) + if commit: + await db.commit() + await db.refresh(subscription) + else: + await db.flush() # Очищаем старые записи об отправленных уведомлениях при замене подписки # (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно - await clear_notifications(db, subscription.id) + await clear_notifications(db, subscription.id, commit=commit) if update_server_counters: try: diff --git a/app/handlers/start.py b/app/handlers/start.py index b8a49355..430a4836 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1,7 +1,10 @@ +from collections.abc import Callable from datetime import UTC, datetime +from typing import Any import structlog from aiogram import Bot, Dispatcher, F, types +from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext @@ -21,7 +24,7 @@ from app.database.crud.user import ( get_user_by_telegram_id, ) 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 ( get_back_keyboard, get_language_selection_keyboard, @@ -60,6 +63,73 @@ from app.utils.user_utils import generate_unique_referral_code 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'🎁 Подарок активирован!\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( db: AsyncSession, phantom: 'User', @@ -446,6 +516,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if state_needs_update: 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: campaign = await get_campaign_by_start_parameter( db, @@ -553,6 +637,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, except Exception as 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) 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, ) + # 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() 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 ) + # 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() if campaign_message: diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 0ecc9b77..953a5261 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -831,10 +831,9 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif connected_squads=tariff.allowed_squads or [], is_trial=False, update_server_counters=True, + commit=False, ) subscription.tariff_id = tariff.id - await db.commit() - await db.refresh(subscription, ['tariff']) else: subscription = await create_paid_subscription( db=db, @@ -845,6 +844,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif connected_squads=tariff.allowed_squads or [], tariff_id=tariff.id, update_server_counters=True, + commit=False, ) 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) if user.auth_type == 'email' and not purchase.is_gift: purchase.auto_login_token = create_auto_login_token(user.id) + + # Single atomic commit: subscription + purchase status + user changes await db.commit() await db.refresh(purchase, attribute_names=['landing', 'user']) @@ -923,6 +925,8 @@ async def retry_stuck_paid_purchases( GuestPurchase.status == GuestPurchaseStatus.PAID.value, or_(GuestPurchase.paid_at < cutoff, 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()) .limit(limit) @@ -943,3 +947,49 @@ async def retry_stuck_paid_purchases( logger.exception('Failed to retry stuck purchase', token_prefix=token[:5]) 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 diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 6011abe7..f2bb911c 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1682,11 +1682,15 @@ class MonitoringService: async def _retry_stuck_guest_purchases(self, db: AsyncSession): 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) if 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: logger.error('Error retrying stuck guest purchases', exc_info=True) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 45f94d3d..c3d1ec93 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -521,6 +521,17 @@ async def try_fulfill_guest_purchase( 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) await fulfill_purchase(db, purchase_token) diff --git a/migrations/alembic/versions/0035_guest_purchase_token_pattern_ops_index.py b/migrations/alembic/versions/0035_guest_purchase_token_pattern_ops_index.py new file mode 100644 index 00000000..0b544914 --- /dev/null +++ b/migrations/alembic/versions/0035_guest_purchase_token_pattern_ops_index.py @@ -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')