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: