830e64afe0
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах - subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs - monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч - logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове - logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв * fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id Трейс показал: subscription.user падает на lazy-load → pool._checkout → do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не поддерживает sync-lazy-load для relationships. Причина рассинхрона: lock_user_for_pricing делает populate_existing=True + selectinload( User.subscriptions).selectinload(Subscription.tariff), что разгружает Subscription.user backref для сестринских подписок того же user. Последующее обращение sub.user у другой подписки падает. Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию делаем fresh refetch через async select с eager load user+tariff+ promo_group. Никаких lazy access в горячем пути. В except используем локально захваченные id вместо getattr(subscription, ...), чтобы логирование не падало каскадом на expired объекте. * fix: grant all available squads for unrestricted trials (#2897) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook * style: ruff format main.py --------- Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not update first_name/last_name from OIDC claims (#2892) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal - balance/platega: re-set FSM state after min/max validation errors, set state before pending_amount path, use balance_topup callback for back button - balance/main: set FSM state and payment_method in handle_topup_amount_callback for all providers before routing, use balance_topup callback in validation errors - payment/paypear: fix confirmation_url key (was 'url'), add fallback, store charged amount with commission for correct webhook amount comparison - tariff_purchase: redirect to active tariff list when current tariff is inactive (hidden trial after promo code activation) - cabinet/renewal: check tariff.is_active in both GET and POST endpoints to prevent hidden trial tariff periods from appearing * fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard - pricing_engine: use shortest period for daily rate comparison instead of period closest to remaining_days — fixes incorrect free/zero cost for upgrades when tariffs have different period sets - pricing_engine: remove unused target_days parameter from get_tariff_daily_rate_fraction - admin_users: add duplicate subscription check before create, change_tariff and activate actions to prevent UniqueViolationError on uq_subscriptions_user_tariff_active constraint - admin_users: add IntegrityError fallback on create as TOCTOU safety net * feat: tariff switch direction control, fix device pricing within tariff limit Tariff switch direction: - Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED settings to control allowed switch directions - Guard all 10 entry points: instant switch (list, preview, confirm), legacy switch (list, select, confirm, daily confirm), cabinet (preview, execute), purchase-options API - Filter tariff lists by allowed direction, show "unavailable" when both directions disabled - Expose settings in cabinet purchase-options response for frontend Device pricing fix: - Devices within tariff.device_limit are now free when restoring (was charging for all devices regardless of tariff inclusion) - Fix max(100, price) minimum enforcing 1 RUB even when chargeable_devices is 0 - Apply fix across all endpoints: bot handlers (confirm_change, execute_change, confirm_add), cabinet API (legacy purchase, modern purchase, get-price, save-cart), inline keyboard display * fix: classic mode renewal resets device_limit to 1 via cart key mismatch - Fix cart key mismatch: extend cart saved 'device_limit' but confirm_purchase read 'devices' key, falling back to DEFAULT=1. Now both keys are saved in both cart-save paths - Fix confirm_purchase device resolution: use explicit is None checks instead of or-chain to avoid falsy-zero trap - Fix return_to_saved_cart display: fall back to 'device_limit' and 'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent - Fix second cart-save path in _extend_existing_subscription with same dual-key pattern - Fix RemnaWaveService import path in renewal service - Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices (hwid) via RemnaWave API on each subscription renewal * fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports - Increase icon max_length from 10 to 100 in all three schemas (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest) to support Telegram Custom Emoji IDs - Add traffic_topup_enabled condition to ButtonConditions schema - Remove shadowing local imports of MenuLayoutService in routes/menu_layout.py (top-level import already provides access) * feat(tickets): multi-media message gallery (media_items JSONB) - Add media_items JSONB column to TicketMessage model for multi-media gallery support (photos/videos/documents in one bubble) - Add TicketMediaItem schema with type validation and shared _validate_media_bundle helper (max 10 items, legacy field compat) - Update admin and user ticket handlers to store media_items and back-fill legacy media_type/media_file_id/media_caption from first item for backward compatibility - Update _message_to_response in both admin and user routes to include media_items in API responses - Allow empty message text when media is attached (message field now defaults to empty string with model validator ensuring text or media) - Add migration 0061 with idempotent column check Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: ticket media_items review fixes - Add if has_media else None guards in user-side ticket handlers (create_ticket, add_message) matching admin handler pattern - Fix Telegram notification using resolved primary_file_id/primary_type instead of raw request fields for gallery messages - Narrow except Exception to (TypeError, KeyError, ValueError) in _message_to_response with warning log for debugging - Add media_items parameter to TicketCRUD.create_ticket and TicketCRUD.add_message for CRUD layer parity - Add TicketMediaItemResponse and media_items field to webapi TicketMessageResponse to prevent data loss on read * feat: landing page analytics goals and sticky pay button - Add sticky_pay_button, analytics_view_enabled, analytics_view_goal, analytics_click_enabled, analytics_click_goal columns to LandingPage - Add fields to CRUD updatable fields, admin create/update/detail schemas, create_landing() kwargs, _landing_to_detail() response - Expose sticky_pay_button and analytics fields in public landing config response for frontend Yandex Metrika integration - Add migration 0062 with idempotent column checks Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: validate analytics goal is set when analytics is enabled on landing Prevent enabling analytics_view/click without providing the corresponding goal identifier, which would result in empty Yandex Metrika calls on the frontend. * feat: Yandex Metrika offline conversions + S2S postbacks - Add YandexClientIdMap model for user → yandex_cid mapping with upsert-safe CRUD (ON CONFLICT DO UPDATE) - Add yandex_cid, subid, referrer columns to GuestPurchase - Add yandex_offline_conv_service: Measurement Protocol integration with mc.yandex.ru/collect (registration, trial, purchase events), background task management, CID parsing from /start params - Add s2s_postback_service: server-to-server affiliate postbacks with URL template placeholders and URL-safe encoding - Add analytics offline conversion info to branding API (masked secret) - Add POST /analytics/yandex-cid endpoint for cabinet CID capture - Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*) - Add migration 0063 (yandex_client_id_map table + guest_purchases cols) - Fix: mask measurement secret aggressively (show only last 4 chars) - Fix: always replace {user_id} placeholder in S2S postback URLs - Fix: use structlog kwargs instead of f-strings with LOG_PREFIX Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded --------- Co-authored-by: c0mrade <killmy666@gmail.com> Co-authored-by: Danila Yudin <danyayudin2012@gmail.com> Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com> Co-authored-by: Dmitry Lunin <br@slack.ru>
563 lines
28 KiB
Python
563 lines
28 KiB
Python
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
import structlog
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.promo_group import get_promo_group_by_id
|
||
from app.database.crud.promocode import (
|
||
check_user_promocode_usage,
|
||
create_promocode_use,
|
||
get_active_discount_promocode_for_user,
|
||
get_promocode_by_code,
|
||
)
|
||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||
from app.database.crud.user_promo_group import add_user_to_promo_group, has_user_promo_group
|
||
from app.database.models import PromoCode, PromoCodeType, SubscriptionStatus, User
|
||
from app.services.remnawave_service import RemnaWaveService
|
||
from app.services.subscription_service import SubscriptionService
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
class _SelectSubscriptionRequired(Exception):
|
||
"""Raised when multi-tariff promo requires user to select a subscription."""
|
||
|
||
def __init__(self, eligible_subscriptions: list[dict], code: str):
|
||
self.eligible_subscriptions = eligible_subscriptions
|
||
self.code = code
|
||
super().__init__('select_subscription')
|
||
|
||
|
||
class PromoCodeService:
|
||
def __init__(self):
|
||
self.remnawave_service = RemnaWaveService()
|
||
self.subscription_service = SubscriptionService()
|
||
|
||
@staticmethod
|
||
def _format_user_log(user: User) -> str:
|
||
"""Форматирует идентификатор пользователя для логов (поддержка email-only users)."""
|
||
if user.telegram_id:
|
||
return str(user.telegram_id)
|
||
if user.email:
|
||
return f'{user.id} ({user.email})'
|
||
return f'#{user.id}'
|
||
|
||
async def activate_promocode(
|
||
self, db: AsyncSession, user_id: int, code: str, *, subscription_id: int | None = None
|
||
) -> dict[str, Any]:
|
||
try:
|
||
user = await get_user_by_id(db, user_id)
|
||
if not user:
|
||
return {'success': False, 'error': 'user_not_found'}
|
||
|
||
promocode = await get_promocode_by_code(db, code)
|
||
if not promocode:
|
||
return {'success': False, 'error': 'not_found'}
|
||
|
||
if not promocode.is_valid:
|
||
if promocode.current_uses >= promocode.max_uses:
|
||
return {'success': False, 'error': 'used'}
|
||
if not promocode.is_active:
|
||
return {'success': False, 'error': 'inactive'}
|
||
from app.database.models import _aware
|
||
|
||
now = datetime.now(UTC)
|
||
aware_from = _aware(promocode.valid_from)
|
||
if aware_from is not None and aware_from > now:
|
||
return {'success': False, 'error': 'not_yet_valid'}
|
||
return {'success': False, 'error': 'expired'}
|
||
|
||
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
|
||
if existing_use:
|
||
return {'success': False, 'error': 'already_used_by_user'}
|
||
|
||
# Лимит на количество активаций за день (анти-стакинг)
|
||
from app.database.crud.promocode import count_user_recent_activations
|
||
|
||
recent_count = await count_user_recent_activations(db, user_id, hours=24)
|
||
if recent_count >= 5:
|
||
logger.warning(
|
||
'Promo stacking limit: user has activations in 24h',
|
||
_format_user_log=self._format_user_log(user),
|
||
recent_count=recent_count,
|
||
)
|
||
return {'success': False, 'error': 'daily_limit'}
|
||
|
||
# Проверка "только для первой покупки"
|
||
if getattr(promocode, 'first_purchase_only', False):
|
||
if getattr(user, 'has_had_paid_subscription', False):
|
||
return {'success': False, 'error': 'not_first_purchase'}
|
||
|
||
balance_before_kopeks = user.balance_kopeks
|
||
|
||
# Резервируем запись использования ДО применения эффектов (защита от race condition)
|
||
promo_use = await create_promocode_use(db, promocode.id, user_id)
|
||
if promo_use is None:
|
||
return {'success': False, 'error': 'already_used_by_user'}
|
||
|
||
try:
|
||
result_description = await self._apply_promocode_effects(
|
||
db, user, promocode, subscription_id=subscription_id
|
||
)
|
||
except _SelectSubscriptionRequired as e:
|
||
# Мульти-тариф: нужен выбор подписки — откатываем использование и коммитим
|
||
await db.delete(promo_use)
|
||
await db.commit()
|
||
return {
|
||
'success': False,
|
||
'error': 'select_subscription',
|
||
'eligible_subscriptions': e.eligible_subscriptions,
|
||
'code': e.code,
|
||
}
|
||
except ValueError as e:
|
||
# Эффекты не применены — удаляем зарезервированную запись использования и коммитим
|
||
await db.delete(promo_use)
|
||
await db.commit()
|
||
error_key = str(e)
|
||
if error_key in (
|
||
'active_discount_exists',
|
||
'no_subscription_for_days',
|
||
'subscription_not_found',
|
||
):
|
||
return {'success': False, 'error': error_key}
|
||
raise
|
||
balance_after_kopeks = user.balance_kopeks
|
||
|
||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||
|
||
await mark_user_as_had_paid_subscription(db, user)
|
||
|
||
logger.info(
|
||
'🎯 Пользователь получил платную подписку через промокод',
|
||
_format_user_log=self._format_user_log(user),
|
||
code=code,
|
||
)
|
||
|
||
# Assign promo group if promocode has one
|
||
if promocode.promo_group_id:
|
||
try:
|
||
# Check if user already has this promo group
|
||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||
|
||
if not has_group:
|
||
# Get promo group details
|
||
promo_group = await get_promo_group_by_id(db, promocode.promo_group_id)
|
||
|
||
if promo_group:
|
||
# Add promo group to user
|
||
await add_user_to_promo_group(
|
||
db, user_id, promocode.promo_group_id, assigned_by='promocode', commit=False
|
||
)
|
||
|
||
logger.info(
|
||
'🎯 Пользователю назначена промогруппа (приоритет: ) через промокод',
|
||
_format_user_log=self._format_user_log(user),
|
||
promo_group_name=promo_group.name,
|
||
priority=promo_group.priority,
|
||
code=code,
|
||
)
|
||
|
||
# Add to result description
|
||
result_description += f'\n🎁 Назначена промогруппа: {promo_group.name}'
|
||
else:
|
||
logger.warning(
|
||
'⚠️ Промогруппа ID не найдена для промокода',
|
||
promo_group_id=promocode.promo_group_id,
|
||
code=code,
|
||
)
|
||
else:
|
||
logger.info(
|
||
'ℹ️ Пользователь уже имеет промогруппу ID',
|
||
_format_user_log=self._format_user_log(user),
|
||
promo_group_id=promocode.promo_group_id,
|
||
)
|
||
except Exception as pg_error:
|
||
logger.error(
|
||
'❌ Ошибка назначения промогруппы для пользователя при активации промокода',
|
||
_format_user_log=self._format_user_log(user),
|
||
code=code,
|
||
pg_error=pg_error,
|
||
)
|
||
# Don't fail the whole promocode activation if promo group assignment fails
|
||
|
||
from sqlalchemy import update as sql_update
|
||
|
||
await db.execute(
|
||
sql_update(PromoCode)
|
||
.where(PromoCode.id == promocode.id)
|
||
.values(current_uses=PromoCode.current_uses + 1)
|
||
)
|
||
await db.commit()
|
||
|
||
logger.info('✅ Пользователь активировал промокод', _format_user_log=self._format_user_log(user), code=code)
|
||
|
||
promocode_data = {
|
||
'code': promocode.code,
|
||
'type': promocode.type,
|
||
'balance_bonus_kopeks': promocode.balance_bonus_kopeks,
|
||
'subscription_days': promocode.subscription_days,
|
||
'max_uses': promocode.max_uses,
|
||
'current_uses': promocode.current_uses + 1, # +1 because we just incremented atomically
|
||
'valid_until': promocode.valid_until,
|
||
'promo_group_id': promocode.promo_group_id,
|
||
}
|
||
|
||
return {
|
||
'success': True,
|
||
'description': result_description,
|
||
'promocode': promocode_data,
|
||
'balance_before_kopeks': balance_before_kopeks,
|
||
'balance_after_kopeks': balance_after_kopeks,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error('Ошибка активации промокода для пользователя', code=code, user_id=user_id, error=e)
|
||
await db.rollback()
|
||
return {'success': False, 'error': 'server_error'}
|
||
|
||
async def _apply_promocode_effects(
|
||
self, db: AsyncSession, user: User, promocode: PromoCode, *, subscription_id: int | None = None
|
||
) -> str:
|
||
"""
|
||
Применяет эффекты промокода к пользователю.
|
||
|
||
Args:
|
||
db: Сессия базы данных
|
||
user: Пользователь
|
||
promocode: Промокод
|
||
|
||
Returns:
|
||
Описание примененных эффектов
|
||
|
||
Raises:
|
||
ValueError: Если у пользователя уже есть активная скидка (для DISCOUNT типа)
|
||
"""
|
||
effects = []
|
||
|
||
# Обработка DISCOUNT типа (одноразовая скидка)
|
||
if promocode.type == PromoCodeType.DISCOUNT.value:
|
||
# Проверка на наличие активной скидки
|
||
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
|
||
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
|
||
|
||
# Если есть активная скидка (процент > 0 и срок не истек)
|
||
if current_discount > 0:
|
||
if expires_at is None or expires_at > datetime.now(UTC):
|
||
logger.warning(
|
||
'⚠️ Пользователь попытался активировать промокод но у него уже есть активная скидка до',
|
||
_format_user_log=self._format_user_log(user),
|
||
code=promocode.code,
|
||
current_discount=current_discount,
|
||
expires_at=expires_at,
|
||
)
|
||
raise ValueError('active_discount_exists')
|
||
|
||
# balance_bonus_kopeks хранит процент скидки (1-100)
|
||
discount_percent = promocode.balance_bonus_kopeks
|
||
# subscription_days хранит срок действия скидки в часах (0 = бессрочно до первой покупки)
|
||
discount_hours = promocode.subscription_days
|
||
|
||
# Устанавливаем процент скидки
|
||
user.promo_offer_discount_percent = discount_percent
|
||
user.promo_offer_discount_source = f'promocode:{promocode.code}'
|
||
|
||
# Устанавливаем срок действия скидки
|
||
if discount_hours > 0:
|
||
user.promo_offer_discount_expires_at = datetime.now(UTC) + timedelta(hours=discount_hours)
|
||
effects.append(f'💸 Получена скидка {discount_percent}% (действует {discount_hours} ч.)')
|
||
else:
|
||
# 0 часов = бессрочно до первой покупки
|
||
user.promo_offer_discount_expires_at = None
|
||
effects.append(f'💸 Получена скидка {discount_percent}% до первой покупки')
|
||
|
||
await db.flush()
|
||
|
||
logger.info(
|
||
'✅ Пользователю назначена скидка (срок: ч.) по промокоду',
|
||
_format_user_log=self._format_user_log(user),
|
||
discount_percent=discount_percent,
|
||
discount_hours=discount_hours,
|
||
code=promocode.code,
|
||
)
|
||
|
||
if promocode.type == PromoCodeType.BALANCE.value and promocode.balance_bonus_kopeks > 0:
|
||
await add_user_balance(db, user, promocode.balance_bonus_kopeks, f'Бонус по промокоду {promocode.code}')
|
||
|
||
balance_bonus_rubles = promocode.balance_bonus_kopeks / 100
|
||
effects.append(f'💰 Баланс пополнен на {balance_bonus_rubles}₽')
|
||
|
||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||
if settings.is_multi_tariff_enabled():
|
||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||
|
||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||
else:
|
||
single_sub = await get_subscription_by_user_id(db, user.id)
|
||
active_subs = [single_sub] if single_sub else []
|
||
|
||
if not active_subs:
|
||
raise ValueError('no_subscription_for_days')
|
||
|
||
# Multi-tariff: require subscription selection if >1 non-daily subscriptions
|
||
non_daily = [s for s in active_subs if not (s.tariff and getattr(s.tariff, 'is_daily', False))]
|
||
eligible = non_daily or active_subs
|
||
|
||
if subscription_id:
|
||
target_sub = next((s for s in eligible if s.id == subscription_id), None)
|
||
if not target_sub:
|
||
raise ValueError('subscription_not_found')
|
||
elif len(eligible) == 1:
|
||
target_sub = eligible[0]
|
||
elif len(eligible) > 1 and settings.is_multi_tariff_enabled():
|
||
# Need user to choose — raise with eligible subscriptions list
|
||
raise _SelectSubscriptionRequired(
|
||
eligible_subscriptions=[
|
||
{'id': s.id, 'tariff_name': s.tariff.name if s.tariff else f'#{s.id}', 'days_left': s.days_left}
|
||
for s in eligible
|
||
],
|
||
code=promocode.code,
|
||
)
|
||
# Prefer non-daily subscription with most days remaining
|
||
elif eligible:
|
||
target_sub = max(eligible, key=lambda s: s.days_left)
|
||
else:
|
||
# eligible = non_daily or active_subs, active_subs is guaranteed non-empty (guard above)
|
||
# This branch is unreachable, but defend against future changes
|
||
raise ValueError('no_subscription_for_days')
|
||
# Конвертация триала в платную подписку при активации промокода на дни
|
||
if target_sub.is_trial:
|
||
target_sub.is_trial = False
|
||
if target_sub.status == SubscriptionStatus.TRIAL.value:
|
||
target_sub.status = SubscriptionStatus.ACTIVE.value
|
||
target_sub.updated_at = datetime.now(UTC)
|
||
logger.info(
|
||
'🎓 Промокод: конвертация триала в платную подписку',
|
||
subscription_id=target_sub.id,
|
||
code=promocode.code,
|
||
)
|
||
|
||
await extend_subscription(db, target_sub, promocode.subscription_days)
|
||
await self.subscription_service.update_remnawave_user(db, target_sub)
|
||
|
||
tariff_label = ''
|
||
if settings.is_multi_tariff_enabled() and getattr(target_sub, 'tariff', None):
|
||
tariff_label = f' «{target_sub.tariff.name}»'
|
||
effects.append(f'⏰ Подписка{tariff_label} продлена на {promocode.subscription_days} дней')
|
||
logger.info(
|
||
'✅ Подписка пользователя продлена на дней в RemnaWave',
|
||
_format_user_log=self._format_user_log(user),
|
||
subscription_days=promocode.subscription_days,
|
||
subscription_id=target_sub.id,
|
||
)
|
||
|
||
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
|
||
from app.database.crud.subscription import create_trial_subscription
|
||
|
||
# Determine trial tariff — use promocode.tariff_id if set, else system default
|
||
trial_tariff = None
|
||
tariff_id_for_trial = None
|
||
trial_traffic_limit = None
|
||
trial_device_limit = None
|
||
trial_squads: list[str] = []
|
||
|
||
try:
|
||
from app.database.crud.tariff import get_tariff_by_id as get_tariff, get_trial_tariff
|
||
|
||
if promocode.tariff_id:
|
||
trial_tariff = await get_tariff(db, promocode.tariff_id)
|
||
else:
|
||
trial_tariff = await get_trial_tariff(db)
|
||
if not trial_tariff:
|
||
trial_tariff_id = settings.get_trial_tariff_id()
|
||
if trial_tariff_id > 0:
|
||
trial_tariff = await get_tariff(db, trial_tariff_id)
|
||
|
||
if trial_tariff:
|
||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||
|
||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||
trial_device_limit = trial_tariff.device_limit
|
||
tariff_id_for_trial = trial_tariff.id
|
||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||
except Exception as e:
|
||
logger.error('Ошибка получения тарифа для триального промокода', error=e)
|
||
|
||
# Check if user already has a subscription with the same tariff
|
||
existing_same_tariff_sub = None
|
||
can_create_new = True
|
||
if settings.is_multi_tariff_enabled():
|
||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||
|
||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||
if tariff_id_for_trial:
|
||
existing_same_tariff_sub = next(
|
||
(s for s in active_subs if s.tariff_id == tariff_id_for_trial), None
|
||
)
|
||
else:
|
||
# No tariff configured — block if any subscription exists
|
||
can_create_new = len(active_subs) == 0
|
||
else:
|
||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||
if existing_sub:
|
||
if tariff_id_for_trial and existing_sub.tariff_id == tariff_id_for_trial:
|
||
existing_same_tariff_sub = existing_sub
|
||
else:
|
||
can_create_new = False
|
||
|
||
trial_days = (
|
||
promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||
)
|
||
# Override with tariff trial_duration_days if available
|
||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if trial_tariff else None
|
||
if tariff_trial_days and promocode.subscription_days <= 0:
|
||
trial_days = tariff_trial_days
|
||
|
||
if existing_same_tariff_sub:
|
||
# User already has this tariff — extend it
|
||
await extend_subscription(db, existing_same_tariff_sub, trial_days)
|
||
await self.subscription_service.update_remnawave_user(db, existing_same_tariff_sub)
|
||
|
||
effects.append(
|
||
f'⏰ Подписка «{trial_tariff.name if trial_tariff else ""}» продлена на {trial_days} дней'
|
||
)
|
||
logger.info(
|
||
'✅ Триал промокод: продлена существующая подписка',
|
||
_format_user_log=self._format_user_log(user),
|
||
trial_days=trial_days,
|
||
subscription_id=existing_same_tariff_sub.id,
|
||
)
|
||
elif can_create_new:
|
||
if trial_device_limit is None and not settings.is_devices_selection_enabled():
|
||
trial_device_limit = settings.get_disabled_mode_device_limit()
|
||
|
||
trial_subscription = await create_trial_subscription(
|
||
db,
|
||
user.id,
|
||
duration_days=trial_days,
|
||
traffic_limit_gb=trial_traffic_limit,
|
||
device_limit=trial_device_limit,
|
||
connected_squads=trial_squads or None,
|
||
tariff_id=tariff_id_for_trial,
|
||
)
|
||
|
||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||
|
||
effects.append(f'🎁 Активирована тестовая подписка на {trial_days} дней')
|
||
logger.info(
|
||
'✅ Создана триал подписка для пользователя на дней',
|
||
_format_user_log=self._format_user_log(user),
|
||
trial_days=trial_days,
|
||
tariff_id=tariff_id_for_trial,
|
||
)
|
||
else:
|
||
effects.append('ℹ️ У вас уже есть активная подписка')
|
||
|
||
return '\n'.join(effects) if effects else '✅ Промокод активирован'
|
||
|
||
async def deactivate_discount_promocode(
|
||
self,
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
*,
|
||
admin_initiated: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Деактивирует активный промокод на процентную скидку у пользователя.
|
||
|
||
Действия:
|
||
- Сбрасывает promo_offer_discount_percent / source / expires_at на пользователе
|
||
- Удаляет запись PromoCodeUse (чтобы промокод мог быть повторно использован, если max_uses > current_uses)
|
||
- Декрементирует current_uses на промокоде
|
||
- Если промокод назначил промогруппу -- снимает её с пользователя
|
||
|
||
Args:
|
||
db: Сессия БД
|
||
user_id: ID пользователя
|
||
admin_initiated: True если деактивацию инициировал админ
|
||
|
||
Returns:
|
||
dict с ключами success, error (опционально), deactivated_code (опционально)
|
||
"""
|
||
try:
|
||
user = await get_user_by_id(db, user_id)
|
||
if not user:
|
||
return {'success': False, 'error': 'user_not_found'}
|
||
|
||
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
|
||
source = getattr(user, 'promo_offer_discount_source', None)
|
||
|
||
if current_discount <= 0 or not source or not source.startswith('promocode:'):
|
||
return {'success': False, 'error': 'no_active_discount_promocode'}
|
||
|
||
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
|
||
# Если скидка уже истекла по времени -- тоже нечего деактивировать
|
||
if expires_at is not None and expires_at <= datetime.now(UTC):
|
||
# Просто зачистим протухшие данные
|
||
user.promo_offer_discount_percent = 0
|
||
user.promo_offer_discount_source = None
|
||
user.promo_offer_discount_expires_at = None
|
||
user.updated_at = datetime.now(UTC)
|
||
await db.commit()
|
||
return {'success': False, 'error': 'discount_already_expired'}
|
||
|
||
promocode, promo_use = await get_active_discount_promocode_for_user(db, user_id)
|
||
|
||
deactivated_code = source.split(':', 1)[1]
|
||
|
||
# 1. Сбрасываем скидку на пользователе
|
||
user.promo_offer_discount_percent = 0
|
||
user.promo_offer_discount_source = None
|
||
user.promo_offer_discount_expires_at = None
|
||
user.updated_at = datetime.now(UTC)
|
||
|
||
# 2. Откатываем использование промокода (если нашли запись)
|
||
if promocode and promo_use:
|
||
await db.delete(promo_use)
|
||
if promocode.current_uses > 0:
|
||
promocode.current_uses -= 1
|
||
promocode.updated_at = datetime.now(UTC)
|
||
|
||
# 3. Если промокод назначал промогруппу -- снимаем её
|
||
if promocode.promo_group_id:
|
||
from app.database.crud.user_promo_group import (
|
||
has_user_promo_group,
|
||
remove_user_from_promo_group,
|
||
)
|
||
|
||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||
if has_group:
|
||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id, commit=False)
|
||
logger.info(
|
||
'Снята промогруппа ID у пользователя при деактивации промокода',
|
||
promo_group_id=promocode.promo_group_id,
|
||
_format_user_log=self._format_user_log(user),
|
||
deactivated_code=deactivated_code,
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
initiator = 'администратором' if admin_initiated else 'пользователем'
|
||
logger.info(
|
||
'Промокод (скидка %) деактивирован для пользователя',
|
||
deactivated_code=deactivated_code,
|
||
current_discount=current_discount,
|
||
initiator=initiator,
|
||
_format_user_log=self._format_user_log(user),
|
||
)
|
||
|
||
return {
|
||
'success': True,
|
||
'deactivated_code': deactivated_code,
|
||
'discount_percent': current_discount,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error('Ошибка деактивации промокода для пользователя', user_id=user_id, error=e)
|
||
await db.rollback()
|
||
return {'success': False, 'error': 'server_error'}
|