From 5ffce175dcb8aebf22cf536bfa032c66da284600 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 05:37:12 +0300 Subject: [PATCH 01/12] feat: gift subscription code-only purchase + activation via deep link - Add code-only gift purchase (no recipient required) - Gift activate endpoint: accept PAID + PENDING_ACTIVATION statuses - Bot deep link: /start GIFTCODE_{token} auto-activation for new and existing users - Add _activate_pending_gift_after_registration() helper with savepoint isolation - Security: FOR UPDATE on activation queries to prevent race conditions - Security: rate limiting on activate, ownership check before status leak - Security: uniform 404 responses to prevent token enumeration - Add selectinload for tariff/user/buyer relationships in all gift queries - Add .limit(100) to pending gifts query - Make recipient_type/recipient_value optional in GiftPurchaseRequest schema --- app/cabinet/routes/gift.py | 275 ++++++++++++++++++++++++++++++------ app/cabinet/schemas/gift.py | 41 +++++- app/handlers/start.py | 137 +++++++++++++++++- 3 files changed, 410 insertions(+), 43 deletions(-) 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: From 769d3a0b309fb6be1c3175cd66a0df7cb6e2fb67 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 05:41:05 +0300 Subject: [PATCH 02/12] refactor: deduplicate gift activation in start.py Replace inline gift activation block (30+ lines) with a call to _activate_pending_gift_after_registration() helper. Eliminates code duplication between existing-user and new-user activation paths. --- app/handlers/start.py | 51 +++---------------------------------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 954a725f..ab305e91 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -633,54 +633,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, 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) + if user: + await _activate_pending_gift_after_registration(db, state, user, message.answer) + await state.update_data(pending_gift_token=None) has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) From 05bcac502efb1b4298a1c6be91bba5d7c057b9f0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:03:26 +0300 Subject: [PATCH 03/12] fix: code-only gifts skip fulfillment in gateway webhook + retry service - Gateway webhook: skip fulfill_purchase() for code-only gifts (is_gift=True, no recipient) - Retry service: exclude code-only gifts from stuck PAID retry query - Status endpoint: return is_code_only and purchase_token for code-only gifts --- app/cabinet/routes/gift.py | 4 ++++ app/cabinet/schemas/gift.py | 2 ++ app/services/guest_purchase_service.py | 2 ++ app/services/payment/common.py | 11 +++++++++++ 4 files changed, 19 insertions(+) diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 7c6205fe..459b0c74 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -517,9 +517,13 @@ 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 if is_code_only else None, recipient_contact_value=recipient_contact_value, gift_message=purchase.gift_message, tariff_name=tariff_name, diff --git a/app/cabinet/schemas/gift.py b/app/cabinet/schemas/gift.py index dde0b166..860cb907 100644 --- a/app/cabinet/schemas/gift.py +++ b/app/cabinet/schemas/gift.py @@ -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 diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 0ecc9b77..f96783fb 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -923,6 +923,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) 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) From 4fb72ae6e3bc65d93ab84b594b4ff5b4856c5357 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:20:07 +0300 Subject: [PATCH 04/12] fix: support prefix-based gift code lookup for activation Displayed gift codes (GIFT-XXXXXXXXXXXX) are 12-char prefixes of the full 64-char token. Activation now accepts prefix match (min 8 chars) so both the short display code and full token work. Also fixes Telegram deep link truncation (64-char limit cuts the token). --- app/cabinet/routes/gift.py | 13 ++++++++++++- app/handlers/start.py | 8 +++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 459b0c74..954fa80e 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -633,10 +633,21 @@ async def activate_gift_by_code( 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(GuestPurchase.token == code) + .where(token_filter, GuestPurchase.is_gift.is_(True)) .with_for_update() ) purchase = result.scalars().first() diff --git a/app/handlers/start.py b/app/handlers/start.py index ab305e91..39ae75fa 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -85,10 +85,16 @@ async def _activate_pending_gift_after_registration( 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(GuestPurchase.token == gift_token) + .where(token_filter, GuestPurchase.is_gift.is_(True)) .with_for_update() ) gift_purchase = gift_result.scalars().first() From 38c6adfdb4d4fc786bf6ca34a5d54025126130c0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:23:55 +0300 Subject: [PATCH 05/12] fix: pass full token to svc_activate instead of truncated prefix Telegram truncates start parameters to 64 chars, so gift_token from deep link may be a prefix. svc_activate does exact match internally, so we must pass gift_purchase.token (full token from DB) instead. --- app/handlers/start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 39ae75fa..4745b64b 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -116,7 +116,7 @@ async def _activate_pending_gift_after_registration( 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) + 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' From 0005d59da1e58c38561c8346db89d8475a25d7df Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:33:31 +0300 Subject: [PATCH 06/12] fix: remove begin_nested that breaks activate_purchase transaction activate_purchase -> create_paid_subscription calls db.commit() internally, which closes the savepoint context and causes InvalidRequestError on subsequent db.refresh(). Replace savepoint with a plain commit before calling svc_activate. --- app/handlers/start.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 4745b64b..3651c11c 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -108,15 +108,13 @@ async def _activate_pending_gift_after_registration( ) 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_purchase.token, skip_notification=True) + 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.commit() + 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' From 363ccce56d3e61554ca49d725322a79b05bc65d3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:36:33 +0300 Subject: [PATCH 07/12] fix: refresh user subscription after gift activation in /start After svc_activate creates a subscription, the user object still has stale cached data. Refresh the subscription attribute so the main menu immediately shows the active subscription status. --- app/handlers/start.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/handlers/start.py b/app/handlers/start.py index 3651c11c..9b0b5c5f 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -640,6 +640,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, 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) From b30c73c300019646ea4a0d7e1bf758464ee58f0f Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:44:58 +0300 Subject: [PATCH 08/12] feat: prevent self-activation of gift codes Buyer cannot activate their own gift, both via cabinet API (returns 400 "Cannot activate your own gift") and bot deep link (silently skips activation). --- app/cabinet/routes/gift.py | 7 +++++++ app/handlers/start.py | 1 + 2 files changed, 8 insertions(+) diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 954fa80e..7c9b79d8 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -665,6 +665,13 @@ async def activate_gift_by_code( 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', diff --git a/app/handlers/start.py b/app/handlers/start.py index 9b0b5c5f..1d6178ec 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -107,6 +107,7 @@ async def _activate_pending_gift_after_registration( 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 From 42b6c80a48ad0100d5ddbbe99a096ebb7b292f08 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:48:49 +0300 Subject: [PATCH 09/12] refactor: rename GIFTCODE_ start parameter prefix to GIFT_ --- app/handlers/start.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 1d6178ec..61b5f0b0 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -516,9 +516,9 @@ 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 + # 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 gift_token: logger.info( 'Gift code deep link detected', @@ -637,7 +637,7 @@ 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_ + # 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) From 8a8337f538c3fcaf84b84c34b7a1e38a4ce9d580 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:56:08 +0300 Subject: [PATCH 10/12] fix: add minimum 8-char length check for gift token in bot deep link --- app/handlers/start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 61b5f0b0..ca9a9622 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -519,7 +519,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, # 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 gift_token: + if len(gift_token) >= 8: logger.info( 'Gift code deep link detected', token_prefix=gift_token[:5], From 5c3465647639d6f07432bc3d725bba9396af6c45 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 07:02:14 +0300 Subject: [PATCH 11/12] fix: address review findings from 6-agent audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Truncate tokens to 12 chars in all API responses (SentGift, PendingGift, ReceivedGift, PurchaseStatus, PurchaseResponse, return URL) — full token no longer leaves the server 2. Status endpoint supports prefix-based token lookup 3. create_paid_subscription/replace_subscription accept commit=False — activate_purchase now uses single atomic commit for subscription + purchase status update (fixes double-commit gap) 4. Bot handler uses flush() instead of commit() before svc_activate — consistent with cabinet endpoint, allows rollback on failure 5. Add retry_stuck_pending_activation() for purchases stuck in PENDING_ACTIVATION status (10 min threshold) 6. Add varchar_pattern_ops index for prefix queries on token column --- app/cabinet/routes/gift.py | 19 ++++--- app/database/crud/subscription.py | 16 ++++-- app/handlers/start.py | 2 +- app/services/guest_purchase_service.py | 52 ++++++++++++++++++- app/services/monitoring_service.py | 6 ++- ..._guest_purchase_token_pattern_ops_index.py | 24 +++++++++ 6 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 migrations/alembic/versions/0035_guest_purchase_token_pattern_ops_index.py diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 7c9b79d8..d5a564f4 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -293,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 @@ -332,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, ) @@ -473,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, @@ -492,10 +492,15 @@ async def get_gift_purchase_status( db: AsyncSession = Depends(get_cabinet_db), ): """Get the status of a cabinet gift purchase.""" + 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(GuestPurchase.token == token) + .where(token_filter) ) purchase = result.scalars().first() if purchase is None: @@ -523,7 +528,7 @@ async def get_gift_purchase_status( status=purchase.status, is_gift=True, is_code_only=is_code_only, - purchase_token=purchase.token if is_code_only else None, + 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, @@ -558,7 +563,7 @@ async def get_sent_gifts( sent.append( SentGiftResponse( - token=p.token, + 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, @@ -601,7 +606,7 @@ async def get_received_gifts( received.append( ReceivedGiftResponse( - token=p.token, + 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, diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index ec3160f6..540e00ff 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,8 +302,11 @@ 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), чтобы новые уведомления отправлялись корректно diff --git a/app/handlers/start.py b/app/handlers/start.py index ca9a9622..430a4836 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -114,7 +114,7 @@ async def _activate_pending_gift_after_registration( # 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.commit() + 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( diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index f96783fb..096a824c 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']) @@ -945,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.updated_at < cutoff, GuestPurchase.updated_at.is_(None)), + or_(GuestPurchase.updated_at > max_age, GuestPurchase.updated_at.is_(None)), + GuestPurchase.user_id.isnot(None), + ) + .order_by(GuestPurchase.updated_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/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') From a90d2d936793daaadf116cf314b736a9ebfb7c3b Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 07:09:39 +0300 Subject: [PATCH 12/12] fix: 3 critical issues from second-round review 1. Balance-mode gift purchase leaked full 64-char token in response. Gateway path truncated to [:12] but balance path didn't. Fixed. 2. retry_stuck_pending_activation referenced GuestPurchase.updated_at which doesn't exist on the model. Changed to paid_at (mirrors retry_stuck_paid_purchases pattern). 3. clear_notifications() called db.commit() unconditionally, defeating commit=False in replace_subscription. Added commit parameter with default=True for backward compat, passed through from caller. --- app/cabinet/routes/gift.py | 2 +- app/database/crud/notification.py | 5 +++-- app/database/crud/subscription.py | 2 +- app/services/guest_purchase_service.py | 6 +++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index d5a564f4..6ed3cb1c 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -440,7 +440,7 @@ async def create_gift_purchase( return GiftPurchaseResponse( status='ok', - purchase_token=purchase_token, + purchase_token=purchase_token[:12], warning=recipient_warning, ) 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 540e00ff..7542de5f 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -310,7 +310,7 @@ async def replace_subscription( # Очищаем старые записи об отправленных уведомлениях при замене подписки # (аналогично 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/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 096a824c..953a5261 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -970,11 +970,11 @@ async def retry_stuck_pending_activation( select(GuestPurchase.token) .where( GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value, - or_(GuestPurchase.updated_at < cutoff, GuestPurchase.updated_at.is_(None)), - or_(GuestPurchase.updated_at > max_age, GuestPurchase.updated_at.is_(None)), + 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.updated_at.asc().nulls_first()) + .order_by(GuestPurchase.paid_at.asc().nulls_first()) .limit(limit) ) tokens = result.scalars().all()