diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index dfe78a56..7c6205fe 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 @@ -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,20 +421,22 @@ 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', @@ -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() @@ -464,7 +492,12 @@ 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) + result = await db.execute( + select(GuestPurchase) + .options(selectinload(GuestPurchase.tariff)) + .where(GuestPurchase.token == token) + ) + purchase = result.scalars().first() if purchase is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -493,3 +526,165 @@ async def get_gift_purchase_status( 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, + 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, + 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:] + + result = await db.execute( + select(GuestPurchase) + .options(selectinload(GuestPurchase.tariff)) + .where(GuestPurchase.token == code) + .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', + ) + + 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..dde0b166 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) @@ -87,3 +87,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/handlers/start.py b/app/handlers/start.py index b8a49355..954a725f 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,68 @@ 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 + + gift_result = await db.execute( + select(GuestPurchase) + .options(selectinload(GuestPurchase.tariff)) + .where(GuestPurchase.token == gift_token) + .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) + ): + # Use savepoint so activation failure does not corrupt the parent session + async with db.begin_nested(): + 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_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 +511,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 GIFTCODE_{token} + if start_parameter and start_parameter.startswith('GIFTCODE_'): + gift_token = start_parameter[9:] # Strip "GIFTCODE_" prefix + if gift_token: + 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 +632,56 @@ 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 GIFTCODE_ + current_state_data = await state.get_data() + pending_gift_token = current_state_data.get('pending_gift_token') + if pending_gift_token and user: + try: + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + from app.services.guest_purchase_service import activate_purchase as svc_activate + + gift_result = await db.execute( + select(GuestPurchase) + .options(selectinload(GuestPurchase.tariff)) + .where(GuestPurchase.token == pending_gift_token) + .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) + ): + # Use savepoint so activation failure does not corrupt the parent session + async with db.begin_nested(): + if gift_purchase.user_id is None: + gift_purchase.user_id = user.id + if gift_purchase.status == GuestPurchaseStatus.PAID.value: + gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value + await db.flush() + await svc_activate(db, pending_gift_token, skip_notification=True) + tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else '' + await message.answer( + 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 from deep link', + token_prefix=pending_gift_token[:5], + ) + finally: + await state.update_data(pending_gift_token=None) + has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) pinned_message = await get_active_pinned_message(db) @@ -1364,6 +1493,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 +1814,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: