Files
Egor 830e64afe0 Dev (#2899)
* 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>
2026-04-22 06:08:26 +03:00

563 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'}