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