+266
-44
@@ -8,9 +8,9 @@ import structlog
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.config import settings
|
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.system_setting import get_setting_value
|
||||||
from app.database.crud.tariff import get_tariff_by_id
|
from app.database.crud.tariff import get_tariff_by_id
|
||||||
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
|
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 ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||||
from ..schemas.gift import (
|
from ..schemas.gift import (
|
||||||
|
ActivateGiftRequest,
|
||||||
|
ActivateGiftResponse,
|
||||||
GiftConfigPaymentMethod,
|
GiftConfigPaymentMethod,
|
||||||
GiftConfigResponse,
|
GiftConfigResponse,
|
||||||
GiftConfigSubOption,
|
GiftConfigSubOption,
|
||||||
@@ -35,6 +37,8 @@ from ..schemas.gift import (
|
|||||||
GiftPurchaseResponse,
|
GiftPurchaseResponse,
|
||||||
GiftPurchaseStatusResponse,
|
GiftPurchaseStatusResponse,
|
||||||
PendingGiftResponse,
|
PendingGiftResponse,
|
||||||
|
ReceivedGiftResponse,
|
||||||
|
SentGiftResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,33 +160,37 @@ async def create_gift_purchase(
|
|||||||
detail='Purchases are restricted for this account',
|
detail='Purchases are restricted for this account',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate recipient format
|
# Recipient is optional — when omitted, buyer gets a code to share manually
|
||||||
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
has_recipient = bool(body.recipient_type and 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',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prevent self-gift
|
if has_recipient:
|
||||||
if body.recipient_type == 'telegram':
|
# Validate recipient format
|
||||||
normalized_recipient = body.recipient_value.lstrip('@').lower()
|
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
||||||
if user.username and user.username.lower() == normalized_recipient:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail='Cannot gift to yourself',
|
detail='Invalid email format',
|
||||||
)
|
)
|
||||||
elif body.recipient_type == 'email':
|
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
|
||||||
if user.email and user.email.lower() == body.recipient_value.lower():
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Find tariff and validate period
|
||||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||||
if tariff is None or not tariff.is_active:
|
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}'
|
buyer_contact_value = f'id:{user.telegram_id or user.id}'
|
||||||
|
|
||||||
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
|
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
|
||||||
# Placed after validation gates to prevent zero-cost enumeration.
|
# Only relevant when a recipient is explicitly specified.
|
||||||
# The resolved ID is passed to fulfill_purchase to avoid a duplicate API call.
|
|
||||||
recipient_warning: str | None = None
|
recipient_warning: str | None = None
|
||||||
pre_resolved_telegram_id: int | 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('@')
|
tg_username = body.recipient_value.lstrip('@')
|
||||||
normalized_username = tg_username.lower()
|
normalized_username = tg_username.lower()
|
||||||
|
|
||||||
@@ -253,6 +260,14 @@ async def create_gift_purchase(
|
|||||||
detail='payment_method is required for gateway mode',
|
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:
|
try:
|
||||||
purchase = await create_purchase(
|
purchase = await create_purchase(
|
||||||
db,
|
db,
|
||||||
@@ -264,12 +279,10 @@ async def create_gift_purchase(
|
|||||||
contact_value=buyer_contact_value,
|
contact_value=buyer_contact_value,
|
||||||
payment_method=body.payment_method,
|
payment_method=body.payment_method,
|
||||||
is_gift=True,
|
is_gift=True,
|
||||||
gift_recipient_type=body.recipient_type,
|
|
||||||
gift_recipient_value=body.recipient_value,
|
|
||||||
gift_message=body.gift_message,
|
|
||||||
source='cabinet',
|
source='cabinet',
|
||||||
buyer_user_id=user.id,
|
buyer_user_id=user.id,
|
||||||
commit=False,
|
commit=False,
|
||||||
|
**purchase_kwargs,
|
||||||
)
|
)
|
||||||
except GuestPurchaseError as exc:
|
except GuestPurchaseError as exc:
|
||||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from 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
|
# Build return URL for after payment
|
||||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
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
|
from app.services.payment_service import PaymentService
|
||||||
|
|
||||||
@@ -319,7 +332,7 @@ async def create_gift_purchase(
|
|||||||
|
|
||||||
return GiftPurchaseResponse(
|
return GiftPurchaseResponse(
|
||||||
status='created',
|
status='created',
|
||||||
purchase_token=purchase.token,
|
purchase_token=purchase.token[:12],
|
||||||
payment_url=payment_url,
|
payment_url=payment_url,
|
||||||
warning=recipient_warning,
|
warning=recipient_warning,
|
||||||
)
|
)
|
||||||
@@ -332,6 +345,14 @@ async def create_gift_purchase(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create purchase record
|
# 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:
|
try:
|
||||||
purchase = await create_purchase(
|
purchase = await create_purchase(
|
||||||
db,
|
db,
|
||||||
@@ -343,12 +364,10 @@ async def create_gift_purchase(
|
|||||||
contact_value=buyer_contact_value,
|
contact_value=buyer_contact_value,
|
||||||
payment_method='balance',
|
payment_method='balance',
|
||||||
is_gift=True,
|
is_gift=True,
|
||||||
gift_recipient_type=body.recipient_type,
|
|
||||||
gift_recipient_value=body.recipient_value,
|
|
||||||
gift_message=body.gift_message,
|
|
||||||
source='cabinet',
|
source='cabinet',
|
||||||
buyer_user_id=user.id,
|
buyer_user_id=user.id,
|
||||||
commit=False,
|
commit=False,
|
||||||
|
**balance_purchase_kwargs,
|
||||||
)
|
)
|
||||||
except GuestPurchaseError as exc:
|
except GuestPurchaseError as exc:
|
||||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from 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',
|
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
|
# Create transaction record
|
||||||
transaction = await create_transaction(
|
transaction = await create_transaction(
|
||||||
db,
|
db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
type=TransactionType.GIFT_PAYMENT,
|
type=TransactionType.GIFT_PAYMENT,
|
||||||
amount_kopeks=price_kopeks,
|
amount_kopeks=price_kopeks,
|
||||||
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
|
description=tx_description,
|
||||||
payment_method=PaymentMethod.BALANCE,
|
payment_method=PaymentMethod.BALANCE,
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
@@ -397,24 +421,26 @@ async def create_gift_purchase(
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
type=TransactionType.GIFT_PAYMENT,
|
type=TransactionType.GIFT_PAYMENT,
|
||||||
payment_method=PaymentMethod.BALANCE,
|
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
|
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||||
purchase_token = purchase.token
|
purchase_token = purchase.token
|
||||||
|
|
||||||
# Fulfill the purchase (find/create recipient user, create subscription, notify)
|
# Only fulfill immediately when a specific recipient was provided.
|
||||||
try:
|
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
|
||||||
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
if has_recipient:
|
||||||
except Exception:
|
try:
|
||||||
logger.exception(
|
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
||||||
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
except Exception:
|
||||||
purchase_id=purchase.id,
|
logger.exception(
|
||||||
)
|
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
||||||
|
purchase_id=purchase.id,
|
||||||
|
)
|
||||||
|
|
||||||
return GiftPurchaseResponse(
|
return GiftPurchaseResponse(
|
||||||
status='ok',
|
status='ok',
|
||||||
purchase_token=purchase_token,
|
purchase_token=purchase_token[:12],
|
||||||
warning=recipient_warning,
|
warning=recipient_warning,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -427,12 +453,14 @@ async def get_pending_gifts(
|
|||||||
"""Get pending gift purchases that the current user can activate."""
|
"""Get pending gift purchases that the current user can activate."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(GuestPurchase)
|
select(GuestPurchase)
|
||||||
|
.options(selectinload(GuestPurchase.tariff))
|
||||||
.where(
|
.where(
|
||||||
GuestPurchase.user_id == user.id,
|
GuestPurchase.user_id == user.id,
|
||||||
GuestPurchase.is_gift.is_(True),
|
GuestPurchase.is_gift.is_(True),
|
||||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||||
)
|
)
|
||||||
.order_by(GuestPurchase.created_at.desc())
|
.order_by(GuestPurchase.created_at.desc())
|
||||||
|
.limit(100)
|
||||||
)
|
)
|
||||||
purchases = result.scalars().all()
|
purchases = result.scalars().all()
|
||||||
|
|
||||||
@@ -445,7 +473,7 @@ async def get_pending_gifts(
|
|||||||
|
|
||||||
pending.append(
|
pending.append(
|
||||||
PendingGiftResponse(
|
PendingGiftResponse(
|
||||||
token=p.token,
|
token=p.token[:12],
|
||||||
tariff_name=p.tariff.name if p.tariff else None,
|
tariff_name=p.tariff.name if p.tariff else None,
|
||||||
period_days=p.period_days,
|
period_days=p.period_days,
|
||||||
gift_message=p.gift_message,
|
gift_message=p.gift_message,
|
||||||
@@ -464,7 +492,17 @@ async def get_gift_purchase_status(
|
|||||||
db: AsyncSession = Depends(get_cabinet_db),
|
db: AsyncSession = Depends(get_cabinet_db),
|
||||||
):
|
):
|
||||||
"""Get the status of a cabinet gift purchase."""
|
"""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:
|
if purchase is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -484,12 +522,196 @@ async def get_gift_purchase_status(
|
|||||||
if purchase.gift_recipient_value:
|
if purchase.gift_recipient_value:
|
||||||
recipient_contact_value = 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(
|
return GiftPurchaseStatusResponse(
|
||||||
status=purchase.status,
|
status=purchase.status,
|
||||||
is_gift=True,
|
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,
|
recipient_contact_value=recipient_contact_value,
|
||||||
gift_message=purchase.gift_message,
|
gift_message=purchase.gift_message,
|
||||||
tariff_name=tariff_name,
|
tariff_name=tariff_name,
|
||||||
period_days=purchase.period_days,
|
period_days=purchase.period_days,
|
||||||
warning=purchase.recipient_warning,
|
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):
|
class GiftPurchaseRequest(BaseModel):
|
||||||
tariff_id: int = Field(gt=0)
|
tariff_id: int = Field(gt=0)
|
||||||
period_days: int = Field(gt=0, le=3650)
|
period_days: int = Field(gt=0, le=3650)
|
||||||
recipient_type: str = Field(pattern=r'^(email|telegram)$')
|
recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||||
recipient_value: str = Field(min_length=1, max_length=255)
|
recipient_value: str | None = Field(default=None, max_length=255)
|
||||||
gift_message: str | None = Field(default=None, max_length=1000)
|
gift_message: str | None = Field(default=None, max_length=1000)
|
||||||
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
|
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
|
||||||
payment_method: str | None = Field(default=None, max_length=50)
|
payment_method: str | None = Field(default=None, max_length=50)
|
||||||
@@ -73,6 +73,8 @@ class GiftPurchaseResponse(BaseModel):
|
|||||||
class GiftPurchaseStatusResponse(BaseModel):
|
class GiftPurchaseStatusResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
is_gift: bool = True
|
is_gift: bool = True
|
||||||
|
is_code_only: bool = False
|
||||||
|
purchase_token: str | None = None
|
||||||
recipient_contact_value: str | None = None
|
recipient_contact_value: str | None = None
|
||||||
gift_message: str | None = None
|
gift_message: str | None = None
|
||||||
tariff_name: str | None = None
|
tariff_name: str | None = None
|
||||||
@@ -87,3 +89,40 @@ class PendingGiftResponse(BaseModel):
|
|||||||
gift_message: str | None = None
|
gift_message: str | None = None
|
||||||
sender_display: str | None = None
|
sender_display: str | None = None
|
||||||
created_at: datetime | 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()
|
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.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
|
||||||
await db.commit()
|
if commit:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def clear_notification_by_type(
|
async def clear_notification_by_type(
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ async def create_paid_subscription(
|
|||||||
update_server_counters: bool = False,
|
update_server_counters: bool = False,
|
||||||
is_trial: bool = False,
|
is_trial: bool = False,
|
||||||
tariff_id: int | None = None,
|
tariff_id: int | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> Subscription:
|
) -> Subscription:
|
||||||
end_date = datetime.now(UTC) + timedelta(days=duration_days)
|
end_date = datetime.now(UTC) + timedelta(days=duration_days)
|
||||||
|
|
||||||
@@ -211,8 +212,11 @@ async def create_paid_subscription(
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
await db.commit()
|
if commit:
|
||||||
await db.refresh(subscription)
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
else:
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'💎 Создана платная подписка для пользователя ID: статус',
|
'💎 Создана платная подписка для пользователя ID: статус',
|
||||||
@@ -265,6 +269,7 @@ async def replace_subscription(
|
|||||||
autopay_enabled: bool | None = None,
|
autopay_enabled: bool | None = None,
|
||||||
autopay_days_before: int | None = None,
|
autopay_days_before: int | None = None,
|
||||||
update_server_counters: bool = False,
|
update_server_counters: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
) -> Subscription:
|
) -> Subscription:
|
||||||
"""Перезаписывает параметры существующей подписки пользователя."""
|
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||||
|
|
||||||
@@ -297,12 +302,15 @@ async def replace_subscription(
|
|||||||
subscription.autopay_days_before = new_autopay_days_before
|
subscription.autopay_days_before = new_autopay_days_before
|
||||||
subscription.updated_at = current_time
|
subscription.updated_at = current_time
|
||||||
|
|
||||||
await db.commit()
|
if commit:
|
||||||
await db.refresh(subscription)
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
else:
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
# Очищаем старые записи об отправленных уведомлениях при замене подписки
|
# Очищаем старые записи об отправленных уведомлениях при замене подписки
|
||||||
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
|
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
|
||||||
await clear_notifications(db, subscription.id)
|
await clear_notifications(db, subscription.id, commit=commit)
|
||||||
|
|
||||||
if update_server_counters:
|
if update_server_counters:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+98
-1
@@ -1,7 +1,10 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from aiogram import Bot, Dispatcher, F, types
|
from aiogram import Bot, Dispatcher, F, types
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
@@ -21,7 +24,7 @@ from app.database.crud.user import (
|
|||||||
get_user_by_telegram_id,
|
get_user_by_telegram_id,
|
||||||
)
|
)
|
||||||
from app.database.crud.user_message import get_random_active_message
|
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 (
|
from app.keyboards.inline import (
|
||||||
get_back_keyboard,
|
get_back_keyboard,
|
||||||
get_language_selection_keyboard,
|
get_language_selection_keyboard,
|
||||||
@@ -60,6 +63,73 @@ from app.utils.user_utils import generate_unique_referral_code
|
|||||||
logger = structlog.get_logger(__name__)
|
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(
|
async def _claim_phantom_user(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
phantom: 'User',
|
phantom: 'User',
|
||||||
@@ -446,6 +516,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
|||||||
if state_needs_update:
|
if state_needs_update:
|
||||||
await state.set_data(data)
|
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:
|
if start_parameter:
|
||||||
campaign = await get_campaign_by_start_parameter(
|
campaign = await get_campaign_by_start_parameter(
|
||||||
db,
|
db,
|
||||||
@@ -553,6 +637,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Ошибка отправки уведомления о рекламной кампании', error=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)
|
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||||
|
|
||||||
pinned_message = await get_active_pinned_message(db)
|
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,
|
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()
|
await state.clear()
|
||||||
|
|
||||||
if campaign_message:
|
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
|
'🗑️ 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()
|
await state.clear()
|
||||||
|
|
||||||
if campaign_message:
|
if campaign_message:
|
||||||
|
|||||||
@@ -831,10 +831,9 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
|||||||
connected_squads=tariff.allowed_squads or [],
|
connected_squads=tariff.allowed_squads or [],
|
||||||
is_trial=False,
|
is_trial=False,
|
||||||
update_server_counters=True,
|
update_server_counters=True,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
subscription.tariff_id = tariff.id
|
subscription.tariff_id = tariff.id
|
||||||
await db.commit()
|
|
||||||
await db.refresh(subscription, ['tariff'])
|
|
||||||
else:
|
else:
|
||||||
subscription = await create_paid_subscription(
|
subscription = await create_paid_subscription(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -845,6 +844,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
|||||||
connected_squads=tariff.allowed_squads or [],
|
connected_squads=tariff.allowed_squads or [],
|
||||||
tariff_id=tariff.id,
|
tariff_id=tariff.id,
|
||||||
update_server_counters=True,
|
update_server_counters=True,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await subscription_service.create_remnawave_user(db, subscription)
|
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)
|
purchase.delivered_at = datetime.now(UTC)
|
||||||
if user.auth_type == 'email' and not purchase.is_gift:
|
if user.auth_type == 'email' and not purchase.is_gift:
|
||||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||||
|
|
||||||
|
# Single atomic commit: subscription + purchase status + user changes
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||||
|
|
||||||
@@ -923,6 +925,8 @@ async def retry_stuck_paid_purchases(
|
|||||||
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
||||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||||
or_(GuestPurchase.paid_at > max_age, 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())
|
.order_by(GuestPurchase.paid_at.asc().nulls_first())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -943,3 +947,49 @@ async def retry_stuck_paid_purchases(
|
|||||||
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
|
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
|
||||||
|
|
||||||
return retried
|
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):
|
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
|
||||||
try:
|
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)
|
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
|
||||||
if retried:
|
if retried:
|
||||||
logger.info('Retried stuck guest purchases', retried=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:
|
except Exception:
|
||||||
logger.error('Error retrying stuck guest purchases', exc_info=True)
|
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),
|
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)
|
# Fulfill: create user, subscription, deliver (commits on success)
|
||||||
await fulfill_purchase(db, purchase_token)
|
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