diff --git a/.dockerignore b/.dockerignore index fd9b0abc..56143f4c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,10 @@ __pycache__/ .pytest_cache/ .coverage htmlcov/ +.venv/ +tests/ +.mypy_cache/ +.ruff_cache/ # Environment files .env diff --git a/Dockerfile b/Dockerfile index 8c3ae426..8ce32e2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" +COPY --from=ghcr.io/astral-sh/uv:0.10.7 /uv /uvx /bin/ -COPY requirements.txt . +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt +WORKDIR /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + uv sync --locked --no-dev FROM python:3.13-slim @@ -18,13 +23,8 @@ ARG VERSION="v3.21.0" # x-release-please-version ARG BUILD_DATE ARG VCS_REF -RUN apt-get update && apt-get install -y --no-install-recommends \ - wget \ - && rm -rf /var/lib/apt/lists/* \ - && apt-get clean - -COPY --from=builder /opt/venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" +COPY --from=builder /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:$PATH" RUN groupadd -g 1000 app && \ useradd -u 1000 -g 1000 -m -s /bin/bash app @@ -33,8 +33,7 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data && \ - chown -R app:app /app logs data +RUN mkdir -p logs data && chown app:app logs data USER app @@ -56,7 +55,7 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \ org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \ org.opencontainers.image.vendor="fr1ngg" -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1 CMD ["python", "main.py"] diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index c46af2b0..121a14fe 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1076,8 +1076,6 @@ async def update_user_subscription( # Сбрасываем докупленный трафик при смене тарифа from sqlalchemy import delete as sql_delete - from app.database.models import TrafficPurchase - await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index ff46b124..36bbcaa7 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -250,6 +250,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - if not user.email: return + user_email = user.email # Save before try block — ORM access may fail after rollback + try: from app.services.remnawave_service import RemnaWaveService @@ -269,6 +271,19 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - panel_user = panel_users[0] logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid) + # Check if another user already owns this remnawave_uuid + from app.database.crud.user import get_user_by_remnawave_uuid + + existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid) + if existing_owner and existing_owner.id != user.id: + logger.warning( + 'Panel UUID already belongs to another user, skipping sync', + email=user.email, + panel_uuid=panel_user.uuid, + existing_owner_id=existing_owner.id, + ) + return + # Link user to panel user.remnawave_uuid = panel_user.uuid @@ -344,9 +359,10 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - await db.commit() except Exception as e: - logger.warning('Failed to sync subscription from panel for', email=user.email, error=e) - # Don't rollback - it detaches user object and breaks subsequent operations - # The sync is non-critical, main verification already succeeded + logger.warning('Failed to sync subscription from panel for', email=user_email, error=e) + await db.rollback() + # Refresh user after rollback — object is expired and lazy loads fail in async + await db.refresh(user) @router.post('/telegram', response_model=AuthResponse) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 7bff1bf6..fa7c4a06 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -340,7 +340,11 @@ async def get_renewal_options( # Учитываем докупленные устройства сверх тарифа extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0)) if extra_devices > 0: - tariff_device_price = tariff.device_price_kopeks or settings.PRICE_PER_DEVICE + tariff_device_price = ( + tariff.device_price_kopeks + if tariff.device_price_kopeks is not None + else settings.PRICE_PER_DEVICE + ) # Используем периоды тарифа или стандартные if tariff_periods: @@ -366,23 +370,32 @@ async def get_renewal_options( price_kopeks += extra_devices * tariff_device_price * months # Apply user's discount if any + original_price = price_kopeks discount_percent = 0 if hasattr(user, 'get_promo_discount'): discount_percent = user.get_promo_discount('period', period) if discount_percent > 0: - original_price = price_kopeks price_kopeks = int(price_kopeks * (100 - discount_percent) / 100) - else: - original_price = None + + # Apply promo_offer discount (временная скидка, как в /renew) + promo_offer_discount_percent = get_user_active_promo_discount_percent(user) + if promo_offer_discount_percent > 0: + price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100 + + # Комбинированный процент скидки для отображения + combined_discount = discount_percent + if original_price > 0 and original_price != price_kopeks: + total_discount = original_price - price_kopeks + combined_discount = int(total_discount * 100 / original_price) options.append( RenewalOptionResponse( period_days=period, price_kopeks=price_kopeks, price_rubles=price_kopeks / 100, - discount_percent=discount_percent, - original_price_kopeks=original_price, + discount_percent=combined_discount, + original_price_kopeks=original_price if combined_discount > 0 else None, ) ) @@ -434,7 +447,9 @@ async def renew_subscription( if extra_devices > 0: from app.utils.pricing_utils import calculate_months_from_days - device_price = tariff.device_price_kopeks or settings.PRICE_PER_DEVICE + device_price = ( + tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE + ) months = calculate_months_from_days(request.period_days) price_kopeks += extra_devices * device_price * months @@ -529,6 +544,10 @@ async def renew_subscription( # Extend from end_date or now if expired now = datetime.now(UTC) + was_expired = user.subscription.status in ('expired', 'disabled') or ( + user.subscription.end_date is not None and user.subscription.end_date <= now + ) + if user.subscription.end_date and user.subscription.end_date > now: user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days) else: @@ -538,8 +557,49 @@ async def renew_subscription( user.subscription.status = 'active' user.subscription.is_trial = False + # При продлении истёкшей подписки — сбрасываем докупки трафика (новый период) + if was_expired: + from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id)) + purchased = user.subscription.purchased_traffic_gb or 0 + if purchased > 0: + old_traffic = user.subscription.traffic_limit_gb + user.subscription.traffic_limit_gb = max(0, (user.subscription.traffic_limit_gb or 0) - purchased) + logger.info( + 'Сброс докупок трафика при продлении истёкшей подписки', + old_traffic=old_traffic, + new_traffic=user.subscription.traffic_limit_gb, + ) + user.subscription.purchased_traffic_gb = 0 + user.subscription.traffic_reset_at = None + if settings.RESET_TRAFFIC_ON_PAYMENT: + user.subscription.traffic_used_gb = 0.0 + await db.commit() + # Синхронизируем с RemnaWave + try: + subscription_service = SubscriptionService() + if getattr(user, 'remnawave_uuid', None): + await subscription_service.update_remnawave_user( + db, + user.subscription, + reset_traffic=was_expired and settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason='subscription renewal (cabinet)', + ) + else: + await subscription_service.create_remnawave_user( + db, + user.subscription, + reset_traffic=was_expired and settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason='subscription renewal (cabinet)', + ) + except Exception as e: + logger.error('Failed to sync subscription renewal with RemnaWave', error=e) + # Отправляем уведомление админам о продлении подписки try: from aiogram import Bot @@ -841,25 +901,9 @@ async def purchase_traffic( detail='Failed to charge balance', ) - # Добавляем трафик + # Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит) await add_subscription_traffic(db, subscription, request.gb) - # Обновляем purchased_traffic_gb - current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 - subscription.purchased_traffic_gb = current_purchased + request.gb - - # Устанавливаем дату сброса трафика (только при первой докупке) - # При повторной докупке дата НЕ продлевается - if not subscription.traffic_reset_at: - subscription.traffic_reset_at = datetime.now(UTC) + timedelta(days=30) - logger.info( - 'Set traffic_reset_at for subscription', - subscription_id=subscription.id, - traffic_reset_at=subscription.traffic_reset_at, - ) - - await db.commit() - # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() @@ -1372,7 +1416,9 @@ async def _build_tariff_response( if subscription and subscription.tariff_id == tariff.id: extra_devices_count = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0)) if extra_devices_count > 0: - extra_device_price_per_month = tariff.device_price_kopeks or settings.PRICE_PER_DEVICE + extra_device_price_per_month = ( + tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE + ) periods = [] if tariff.period_prices: @@ -1457,7 +1503,7 @@ async def _build_tariff_response( price_per_day = price_per_day - discount_amount # Apply discount to device price if applicable - device_price = tariff.device_price_kopeks or 0 + device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0 original_device_price = device_price device_discount_percent = 0 if promo_group and device_price > 0: @@ -1904,7 +1950,11 @@ async def purchase_tariff( if not is_daily_tariff: from app.utils.pricing_utils import calculate_months_from_days - device_price_per_month = tariff.device_price_kopeks or settings.PRICE_PER_DEVICE + device_price_per_month = ( + tariff.device_price_kopeks + if tariff.device_price_kopeks is not None + else settings.PRICE_PER_DEVICE + ) months = calculate_months_from_days(period_days) extra_devices_cost = extra_devices * device_price_per_month * months # Применяем скидку промогруппы на устройства @@ -2230,7 +2280,7 @@ async def purchase_devices( tariff = await get_tariff_by_id(db, subscription.tariff_id) # Determine device price and max limit from tariff or settings - if tariff and tariff.device_price_kopeks: + if tariff and tariff.device_price_kopeks is not None: device_price = tariff.device_price_kopeks max_device_limit = tariff.max_device_limit else: @@ -2560,7 +2610,7 @@ async def save_devices_cart( tariff = await get_tariff_by_id(db, subscription.tariff_id) # Determine device price and max limit from tariff or settings - if tariff and tariff.device_price_kopeks: + if tariff and tariff.device_price_kopeks is not None: device_price = tariff.device_price_kopeks max_device_limit = tariff.max_device_limit else: @@ -2632,7 +2682,7 @@ async def get_device_price( tariff = await get_tariff_by_id(db, subscription.tariff_id) # Determine device price and max limit from tariff or settings - if tariff and tariff.device_price_kopeks: + if tariff and tariff.device_price_kopeks is not None: device_price = tariff.device_price_kopeks max_device_limit = tariff.max_device_limit else: @@ -4419,7 +4469,12 @@ async def switch_traffic_package( # Downgrade - no charge, no refund charged = 0 - # Update subscription + # Update subscription — delete TrafficPurchase records before resetting purchased_traffic_gb + from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id)) user.subscription.traffic_limit_gb = new_traffic user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch user.subscription.traffic_reset_at = None # Reset traffic reset date diff --git a/app/config.py b/app/config.py index 2edfa251..1e747a94 100644 --- a/app/config.py +++ b/app/config.py @@ -2598,18 +2598,25 @@ def get_db_period_prices() -> dict[int, int] | None: return _DB_PERIOD_PRICES +def clear_db_period_prices() -> None: + """Очищает кеш цен из тарифов (при переключении в classic mode).""" + global _DB_PERIOD_PRICES + _DB_PERIOD_PRICES = None + + def refresh_period_prices() -> None: """ Rebuild cached period price mapping. - Приоритет: БД > .env + В режиме tariffs: приоритет у _DB_PERIOD_PRICES (из таблицы Tariff). + В режиме classic: ВСЕГДА используются settings.PRICE_*_DAYS. """ PERIOD_PRICES.clear() - if _DB_PERIOD_PRICES: - # Используем цены из БД + if _DB_PERIOD_PRICES and settings.is_tariffs_mode(): + # Используем цены из БД тарифов (только в режиме tariffs) PERIOD_PRICES.update(_DB_PERIOD_PRICES) else: - # Fallback на .env + # Classic mode или нет цен в БД — берём из settings PERIOD_PRICES.update( {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()} ) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index b253fb5a..43cab991 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -106,6 +106,7 @@ async def get_campaigns_list( .options( selectinload(AdvertisingCampaign.tariff), selectinload(AdvertisingCampaign.partner), + selectinload(AdvertisingCampaign.registrations), ) .order_by(AdvertisingCampaign.created_at.desc()) .offset(offset) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index ecf5e945..c33ad8bc 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -281,6 +281,11 @@ async def replace_subscription( subscription.end_date = current_time + timedelta(days=duration_days) subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 + + # Удаляем записи TrafficPurchase перед сбросом purchased_traffic_gb + from app.database.models import TrafficPurchase + + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.device_limit = device_limit @@ -356,6 +361,8 @@ async def extend_subscription( device_limit: Лимит устройств (опционально, для режима тарифов) connected_squads: Список UUID сквадов (опционально, для режима тарифов) """ + from app.database.models import TrafficPurchase + current_time = datetime.now(UTC) logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days) @@ -370,6 +377,12 @@ async def extend_subscription( # Включает переход из классического режима (tariff_id=None) в тарифный is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id) + # Определяем, была ли подписка истёкшей ДО продления (статус меняется ниже) + was_expired = subscription.status in ( + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.DISABLED.value, + ) or (subscription.end_date is not None and subscription.end_date <= current_time) + if is_tariff_change: logger.info('🔄 Обнаружена СМЕНА тарифа: →', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id) @@ -453,23 +466,21 @@ async def extend_subscription( else: subscription.traffic_used_gb = 0.0 - if is_tariff_change: - # При СМЕНЕ тарифа сбрасываем все докупки трафика + if is_tariff_change or was_expired: + # При СМЕНЕ тарифа или ИСТЁКШЕЙ подписке — сбрасываем все докупки трафика subscription.traffic_limit_gb = traffic_limit_gb - from sqlalchemy import delete as sql_delete - - from app.database.models import TrafficPurchase - - await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None + reason = 'смена тарифа' if is_tariff_change else 'подписка была истёкшей' logger.info( - '📊 Обновлен лимит трафика: ГБ → ГБ (смена тарифа, докупки сброшены)', + '📊 Обновлен лимит трафика: ГБ → ГБ (докупки сброшены)', old_traffic=old_traffic, traffic_limit_gb=traffic_limit_gb, + reason=reason, ) else: - # При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик + # Подписка активна, тот же тариф — сохраняем докупленный трафик purchased = subscription.purchased_traffic_gb or 0 subscription.traffic_limit_gb = traffic_limit_gb + purchased logger.info( @@ -480,13 +491,18 @@ async def extend_subscription( ) elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 - # В режиме тарифов сохраняем докупленный трафик при продлении - if subscription.tariff_id is None: + if subscription.tariff_id is None or was_expired: + # Классический режим или истёкшая подписка — сбрасываем докупки + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 - subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info('🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT') + subscription.traffic_reset_at = None + logger.info( + '🔄 Сбрасываем использованный и докупленный трафик', + was_expired=was_expired, + tariff_id=subscription.tariff_id, + ) else: - # При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at + # Активная подписка в режиме тарифов — сохраняем purchased_traffic_gb и traffic_reset_at logger.info('🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)') if device_limit is not None: @@ -529,6 +545,7 @@ async def extend_subscription( old_limit = subscription.traffic_limit_gb if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: subscription.traffic_limit_gb = fixed_limit + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info( @@ -746,18 +763,26 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription) async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) -> list[Subscription]: + from app.database.models import Tariff + threshold_date = datetime.now(UTC) + timedelta(days=days_before) result = await db.execute( select(Subscription) .join(User, Subscription.user_id == User.id) - .options(selectinload(Subscription.user)) + .outerjoin(Tariff, Subscription.tariff_id == Tariff.id) + .options(selectinload(Subscription.user), selectinload(Subscription.tariff)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, User.status == UserStatus.ACTIVE.value, Subscription.end_date <= threshold_date, Subscription.end_date > datetime.now(UTC), + # Не включаем активные суточные подписки — у них end_date всегда +24ч + ~and_( + Tariff.is_daily.is_(True), + Subscription.is_daily_paused.is_(False), + ), ) ) ) @@ -1031,7 +1056,7 @@ async def get_all_subscriptions(db: AsyncSession, page: int = 1, limit: int = 10 result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options(selectinload(Subscription.user), selectinload(Subscription.tariff)) .order_by(Subscription.created_at.desc()) .offset(offset) .limit(limit) @@ -1047,10 +1072,10 @@ async def get_subscriptions_batch( offset: int = 0, limit: int = 500, ) -> list[Subscription]: - """Получает подписки пачками для синхронизации. Загружает связанных пользователей.""" + """Получает подписки пачками для синхронизации. Загружает связанных пользователей и тарифы.""" result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options(selectinload(Subscription.user), selectinload(Subscription.tariff)) .order_by(Subscription.id) .offset(offset) .limit(limit) @@ -1609,6 +1634,19 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S logger.info('⏸️ Суточная подписка на паузе, пропускаем проверку истечения', subscription_id=subscription.id) return subscription + # Активные суточные подписки управляются DailySubscriptionService — не экспайрим их тут. + # end_date у них всего +24ч, и между проверками (30 мин) она может формально истечь. + # Используем getattr(subscription, 'tariff', None) вместо property is_daily_tariff, + # т.к. property может вызвать MissingGreenlet при ленивой загрузке в async-контексте. + tariff = getattr(subscription, 'tariff', None) + is_active_daily = tariff is not None and getattr(tariff, 'is_daily', False) and not is_daily_paused + if is_active_daily: + logger.debug( + '⏩ Активная суточная подписка — пропускаем проверку истечения (управляет DailySubscriptionService)', + subscription_id=subscription.id, + ) + return subscription + if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: # Детальное логирование для отладки проблемы с деактивацией time_diff = current_time - subscription.end_date @@ -2045,6 +2083,55 @@ async def get_disabled_daily_subscriptions_for_resume( return list(subscriptions) +async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list[Subscription]: + """ + Получает EXPIRED суточные подписки, которые были ошибочно экспайрены + middleware или check_and_update_subscription_status. + + Суточные подписки не должны экспайриться — ими управляет DailySubscriptionService. + Если баланс пользователя достаточен, подписку нужно восстановить и списать. + """ + from app.database.models import Tariff + + # Берём только недавно экспайренные (до 24ч) — старые не трогаем + recovery_threshold = datetime.now(UTC) - timedelta(hours=24) + + query = ( + select(Subscription) + .join(Tariff, Subscription.tariff_id == Tariff.id) + .join(User, Subscription.user_id == User.id) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where( + and_( + Tariff.is_daily.is_(True), + Tariff.is_active.is_(True), + Subscription.status == SubscriptionStatus.EXPIRED.value, + User.status == UserStatus.ACTIVE.value, + Subscription.is_daily_paused.is_(False), + Subscription.is_trial.is_(False), + # Только недавно экспайренные + Subscription.updated_at >= recovery_threshold, + # Баланс достаточен для списания + User.balance_kopeks >= Tariff.daily_price_kopeks, + ) + ) + ) + + result = await db.execute(query) + subscriptions = result.scalars().all() + + if subscriptions: + logger.warning( + '⚠️ Найдено EXPIRED суточных подписок для восстановления (ошибочно экспайрены)', + subscriptions_count=len(subscriptions), + ) + + return list(subscriptions) + + async def pause_daily_subscription( db: AsyncSession, subscription: Subscription, diff --git a/app/database/models.py b/app/database/models.py index 11051955..030d1cb5 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -908,6 +908,11 @@ class Tariff(Base): prices = self.period_prices or {} return sorted([int(p) for p in prices.keys()]) + def get_shortest_period(self) -> int | None: + """Возвращает минимальный доступный период в днях (для автопродления).""" + periods = self.get_available_periods() + return periods[0] if periods else None + def get_price_rubles(self, period_days: int) -> float | None: """Возвращает цену в рублях для указанного периода.""" price_kopeks = self.get_price_for_period(period_days) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 07ab7090..ddb33817 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -3,6 +3,7 @@ import re import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext +from sqlalchemy import inspect as sa_inspect from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -237,8 +238,10 @@ async def show_campaigns_list( text_lines = ['📋 Список кампаний\n'] for campaign in campaigns: - registrations = len(campaign.registrations or []) - total_balance = sum(r.balance_bonus_kopeks or 0 for r in campaign.registrations or []) + # Access from instance dict to avoid MissingGreenlet on lazy load + regs = sa_inspect(campaign).dict.get('registrations', []) or [] + registrations = len(regs) + total_balance = sum(r.balance_bonus_kopeks or 0 for r in regs) status = '🟢' if campaign.is_active else '⚪' line = ( f'{status} {campaign.name}{campaign.start_parameter}\n' diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 1990ce1a..c010a6ad 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -456,11 +456,13 @@ async def handle_simple_subscription_pay_with_balance( was_trial = getattr(existing_subscription, 'is_trial', False) subscription = await extend_subscription( - db=db, subscription=existing_subscription, days=subscription_params['period_days'] + db=db, + subscription=existing_subscription, + days=subscription_params['period_days'], + traffic_limit_gb=subscription_params['traffic_limit_gb'], + device_limit=subscription_params['device_limit'], + connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else None, ) - # Обновляем параметры подписки - subscription.traffic_limit_gb = subscription_params['traffic_limit_gb'] - subscription.device_limit = subscription_params['device_limit'] # Если текущая подписка была пробной, и мы обновляем её # нужно изменить статус подписки @@ -471,10 +473,6 @@ async def handle_simple_subscription_pay_with_balance( subscription.status = SubscriptionStatus.ACTIVE.value subscription.is_trial = False - # Устанавливаем новый выбранный сквад - if resolved_squad_uuid: - subscription.connected_squads = [resolved_squad_uuid] - await db.commit() await db.refresh(subscription) else: @@ -2162,11 +2160,13 @@ async def confirm_simple_subscription_purchase( was_trial = getattr(existing_subscription, 'is_trial', False) subscription = await extend_subscription( - db=db, subscription=existing_subscription, days=subscription_params['period_days'] + db=db, + subscription=existing_subscription, + days=subscription_params['period_days'], + traffic_limit_gb=subscription_params['traffic_limit_gb'], + device_limit=subscription_params['device_limit'], + connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else None, ) - # Обновляем параметры подписки - subscription.traffic_limit_gb = subscription_params['traffic_limit_gb'] - subscription.device_limit = subscription_params['device_limit'] # Если текущая подписка была пробной, и мы обновляем её # нужно изменить статус подписки @@ -2177,10 +2177,6 @@ async def confirm_simple_subscription_purchase( subscription.status = SubscriptionStatus.ACTIVE.value subscription.is_trial = False - # Устанавливаем новый выбранный сквад - if resolved_squad_uuid: - subscription.connected_squads = [resolved_squad_uuid] - await db.commit() await db.refresh(subscription) else: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 6516bc0d..ade6b142 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1968,6 +1968,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us current_time = datetime.now(UTC) + was_expired = subscription.status in ( + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.DISABLED.value, + ) or (subscription.end_date is not None and subscription.end_date <= current_time) + if subscription.end_date > current_time: new_end_date = subscription.end_date + timedelta(days=days) else: @@ -1978,6 +1983,29 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = current_time + # При продлении истёкшей подписки — сбрасываем докупки трафика + if was_expired: + from sqlalchemy import delete as sql_delete_tp + + from app.database.models import TrafficPurchase as TrafficPurchaseModel + + await db.execute( + sql_delete_tp(TrafficPurchaseModel).where(TrafficPurchaseModel.subscription_id == subscription.id) + ) + purchased = subscription.purchased_traffic_gb or 0 + if purchased > 0: + old_traffic = subscription.traffic_limit_gb + subscription.traffic_limit_gb = max(0, (subscription.traffic_limit_gb or 0) - purchased) + logger.info( + 'Сброс докупок при продлении истёкшей подписки', + old_traffic=old_traffic, + new_traffic=subscription.traffic_limit_gb, + ) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None + if settings.RESET_TRAFFIC_ON_PAYMENT: + subscription.traffic_used_gb = 0.0 + # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита traffic_was_reset = False old_traffic_limit = subscription.traffic_limit_gb @@ -1986,8 +2014,13 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: traffic_was_reset = True subscription.traffic_limit_gb = fixed_limit + from sqlalchemy import delete as sql_delete_fixed + + from app.database.models import TrafficPurchase as TPFixed + + await db.execute(sql_delete_fixed(TPFixed).where(TPFixed.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 - subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика + subscription.traffic_reset_at = None logger.info( '🔄 Сброс трафика при продлении: ГБ → ГБ', old_traffic_limit=old_traffic_limit, diff --git a/app/middlewares/subscription_checker.py b/app/middlewares/subscription_checker.py index db1f3b8d..5732a756 100644 --- a/app/middlewares/subscription_checker.py +++ b/app/middlewares/subscription_checker.py @@ -40,10 +40,15 @@ class SubscriptionStatusMiddleware(BaseMiddleware): current_time = datetime.now(UTC) subscription = user.subscription + # Суточные подписки управляются DailySubscriptionService — не экспайрим их тут + tariff = getattr(subscription, 'tariff', None) + is_active_daily = tariff and getattr(tariff, 'is_daily', False) and not subscription.is_daily_paused + if ( subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date and subscription.end_date <= current_time + and not is_active_daily ): # Вычисляем насколько давно истекла подписка time_since_expiry = current_time - subscription.end_date diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 49e9aa11..b0470342 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -23,12 +23,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings -from app.database.database import AsyncSessionLocal, engine +from app.database.database import AsyncSessionLocal, engine, sync_postgres_sequences from app.database.models import ( + AccessPolicy, + AdminAuditLog, + AdminRole, AdvertisingCampaign, AdvertisingCampaignRegistration, BroadcastHistory, ButtonClickLog, + CabinetRefreshToken, CloudPaymentsPayment, ContestAttempt, ContestRound, @@ -45,6 +49,7 @@ from app.database.models import ( MonitoringLog, MulenPayPayment, Pal24Payment, + PartnerApplication, PaymentMethodConfig, PinnedMessage, PlategaPayment, @@ -64,6 +69,7 @@ from app.database.models import ( ReferralContestEvent, ReferralContestVirtualParticipant, ReferralEarning, + RequiredChannel, SentNotification, ServerSquad, ServiceRule, @@ -82,8 +88,10 @@ from app.database.models import ( TrafficPurchase, Transaction, User, + UserChannelSubscription, UserMessage, UserPromoGroup, + UserRole, WataPayment, WebApiToken, Webhook, @@ -214,6 +222,16 @@ class BackupService: # --- Support --- TicketNotification, ButtonClickLog, + # --- RBAC / Admin --- + AdminRole, + UserRole, + AccessPolicy, + AdminAuditLog, + # --- Channels / Partners --- + RequiredChannel, + UserChannelSubscription, + PartnerApplication, + CabinetRefreshToken, ] self.backup_models_ordered = self._base_backup_models.copy() @@ -614,7 +632,7 @@ class BackupService: record_dict[column.name] = 0.0 elif isinstance(value, (list, dict)): try: - record_dict[column.name] = json_lib.dumps(value) if value else None + record_dict[column.name] = json_lib.dumps(value) if value is not None else None except TypeError: record_dict[column.name] = str(value) elif hasattr(value, '__dict__'): @@ -1002,6 +1020,14 @@ class BackupService: await db.commit() + # Синхронизируем PostgreSQL sequences после ORM-восстановления, + # чтобы auto-increment ID не конфликтовали с восстановленными данными + try: + await sync_postgres_sequences() + logger.info('🔢 Последовательности PostgreSQL синхронизированы') + except Exception as seq_err: + logger.warning('⚠️ Не удалось синхронизировать sequences', error=seq_err) + except Exception as exc: await db.rollback() logger.error('Ошибка при восстановлении', exc=exc) @@ -1107,10 +1133,10 @@ class BackupService: raise try: - await db.flush() + async with db.begin_nested(): + await db.flush() except IntegrityError as e: - logger.warning('IntegrityError при flush пользователей, откатываем', e=e) - await db.rollback() + logger.warning('IntegrityError при flush пользователей, savepoint откачен', e=e) logger.info('✅ Пользователи без реферальных связей восстановлены') async def _update_user_referrals(self, db: AsyncSession, backup_data: dict): @@ -1375,22 +1401,25 @@ class BackupService: return restored_count async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None): - tables_order = [ - # --- Association tables (no FK deps on them, safe to delete first) --- + # Все таблицы, которые нужно очистить при восстановлении. + # TRUNCATE CASCADE автоматически обработает FK зависимости, + # поэтому порядок не критичен, но перечисляем все для полноты. + all_tables = [ + # --- Association tables --- 'server_squad_promo_groups', 'tariff_promo_groups', 'payment_method_promo_groups', - # --- Polls (child -> parent order) --- + # --- Polls --- 'poll_answers', 'poll_responses', 'poll_options', 'poll_questions', 'polls', - # --- Wheel (child -> parent) --- + # --- Wheel --- 'wheel_spins', 'wheel_prizes', 'wheel_configs', - # --- Contests (child -> parent) --- + # --- Contests --- 'contest_attempts', 'contest_rounds', 'contest_templates', @@ -1428,13 +1457,15 @@ class BackupService: 'privacy_policies', 'public_offers', 'payment_method_configs', - # --- Original tables (preserved order) --- + # --- Support --- 'support_audit_logs', 'ticket_messages', 'tickets', 'cabinet_refresh_tokens', + # --- Campaigns --- 'advertising_campaign_registrations', 'advertising_campaigns', + # --- Subscriptions --- 'subscription_servers', 'sent_notifications', 'discount_offers', @@ -1451,9 +1482,19 @@ class BackupService: 'welcome_texts', 'subscriptions', 'promocodes', + # --- RBAC / Admin (FK → users, must be before users) --- + 'access_policies', + 'user_roles', + 'admin_audit_log', + 'admin_roles', + # --- Channels / Partners --- + 'partner_applications', + 'required_channels', + 'user_channel_subscriptions', + # --- Core --- 'users', 'promo_groups', - 'tariffs', # tariffs должен очищаться ПОСЛЕ subscriptions (FK зависимость) + 'tariffs', 'server_squads', 'squads', 'service_rules', @@ -1466,18 +1507,36 @@ class BackupService: # (чтобы сохранить существующие настройки) preserve_if_no_backup = {'tariffs', 'promo_groups', 'server_squads', 'squads'} - for table_name in tables_order: - # Проверяем, нужно ли сохранить таблицу + # Фильтруем таблицы, которые нужно сохранить + tables_to_truncate = [] + for table_name in all_tables: if backup_data and table_name in preserve_if_no_backup: if not backup_data.get(table_name): logger.info('⏭️ Пропускаем очистку (нет данных в бекапе)', table_name=table_name) continue + tables_to_truncate.append(table_name) - try: - await db.execute(text(f'DELETE FROM {table_name}')) - logger.info('🗑️ Очищена таблица', table_name=table_name) - except Exception as e: - logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e) + if not tables_to_truncate: + return + + # TRUNCATE CASCADE — одна команда для всех таблиц. + # CASCADE автоматически очищает любые таблицы с FK-ссылками, + # даже если они не в нашем списке. Это решает проблему с admin_audit_log и др. + tables_str = ', '.join(tables_to_truncate) + try: + await db.execute(text(f'TRUNCATE {tables_str} RESTART IDENTITY CASCADE')) + logger.info('🗑️ Очищены все таблицы', tables_count=len(tables_to_truncate)) + except Exception as e: + logger.error('❌ Ошибка TRUNCATE CASCADE, пробуем поштучно с savepoints', error=e) + await db.rollback() + # Fallback: поштучная очистка с savepoints + for table_name in tables_to_truncate: + try: + async with db.begin_nested(): + await db.execute(text(f'TRUNCATE {table_name} CASCADE')) + logger.info('🗑️ Очищена таблица', table_name=table_name) + except Exception as table_err: + logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=table_err) async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]: return {} diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index ed9754ac..07c104bf 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -15,13 +15,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.subscription import ( get_daily_subscriptions_for_charge, + get_disabled_daily_subscriptions_for_resume, + get_expired_daily_subscriptions_for_recovery, suspend_daily_subscription_insufficient_balance, update_daily_charge_time, ) from app.database.crud.transaction import create_transaction from app.database.crud.user import get_user_by_id, subtract_user_balance from app.database.database import AsyncSessionLocal -from app.database.models import PaymentMethod, Subscription, TransactionType, User +from app.database.models import PaymentMethod, Subscription, SubscriptionStatus, TransactionType, User from app.localization.texts import get_texts from app.services.notification_delivery_service import ( NotificationType, @@ -476,6 +478,89 @@ class DailySubscriptionService: except Exception as e: logger.warning('Не удалось отправить уведомление о сбросе трафика', error=e) + async def process_auto_resume(self) -> dict: + """ + Возобновляет DISABLED суточные подписки, у которых появился достаточный баланс. + Также восстанавливает EXPIRED подписки, ошибочно экспайренные другими системами. + """ + stats = {'resumed': 0, 'recovered': 0, 'errors': 0} + + try: + async with AsyncSessionLocal() as db: + # 1. Возобновление DISABLED подписок (недостаточно средств → баланс пополнен) + try: + disabled_subs = await get_disabled_daily_subscriptions_for_resume(db) + for subscription in disabled_subs: + try: + # Только активируем — НЕ ставим last_daily_charge_at, + # чтобы _process_single_charge корректно его обновил при списании. + # Если списание упадёт, подписка останется без last_daily_charge_at + # и будет подхвачена на следующем цикле. + subscription.status = SubscriptionStatus.ACTIVE.value + await db.commit() + await db.refresh(subscription) + + logger.info( + '✅ Суточная подписка возобновлена (DISABLED→ACTIVE, баланс пополнен)', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) + + # Списываем за первые сутки — charge обновит end_date и last_daily_charge_at + charge_result = await self._process_single_charge(db, subscription) + if charge_result == 'charged': + stats['resumed'] += 1 + elif charge_result == 'error': + stats['errors'] += 1 + except Exception as e: + logger.error( + 'Ошибка возобновления DISABLED подписки', + subscription_id=subscription.id, + error=e, + exc_info=True, + ) + stats['errors'] += 1 + except Exception as e: + logger.error('Ошибка при обработке DISABLED подписок', error=e, exc_info=True) + + # 2. Восстановление EXPIRED подписок (ошибочно экспайрены middleware/CRUD) + try: + expired_subs = await get_expired_daily_subscriptions_for_recovery(db) + for subscription in expired_subs: + try: + # Восстанавливаем в ACTIVE — charge обновит end_date и last_daily_charge_at + subscription.status = SubscriptionStatus.ACTIVE.value + await db.commit() + await db.refresh(subscription) + + logger.warning( + '🔄 Суточная подписка восстановлена (EXPIRED→ACTIVE, ошибочный expire)', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) + + # Списываем за сутки + charge_result = await self._process_single_charge(db, subscription) + if charge_result == 'charged': + stats['recovered'] += 1 + elif charge_result == 'error': + stats['errors'] += 1 + except Exception as e: + logger.error( + 'Ошибка восстановления EXPIRED подписки', + subscription_id=subscription.id, + error=e, + exc_info=True, + ) + stats['errors'] += 1 + except Exception as e: + logger.error('Ошибка при обработке EXPIRED подписок', error=e, exc_info=True) + + except Exception as e: + logger.error('Ошибка в process_auto_resume', error=e, exc_info=True) + + return stats + async def start_monitoring(self): """Запускает периодическую проверку суточных подписок и сброса трафика.""" self._running = True @@ -485,6 +570,16 @@ class DailySubscriptionService: while self._running: try: + # Восстановление DISABLED/EXPIRED подписок (до основных списаний!) + resume_stats = await self.process_auto_resume() + if resume_stats['resumed'] > 0 or resume_stats['recovered'] > 0: + logger.info( + '📊 Авто-возобновление: возобновлено=, восстановлено=, ошибок=', + resumed=resume_stats['resumed'], + recovered=resume_stats['recovered'], + errors=resume_stats['errors'], + ) + # Обработка суточных списаний stats = await self.process_daily_charges() diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 8e659016..9811f20b 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -61,7 +61,6 @@ from app.services.promo_offer_service import promo_offer_service from app.services.subscription_service import SubscriptionService from app.utils.cache import cache from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.utils.pricing_utils import apply_percentage_discount from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) @@ -218,13 +217,15 @@ class MonitoringService: '🧹 Отозвано истекших тестовых доступов к сквадам', cleaned_test_access=cleaned_test_access ) + # ВАЖНО: autopay ПЕРЕД check_expired — иначе подписки с автоплатой + # экспайрятся до того, как autopay успеет их продлить + if settings.ENABLE_AUTOPAY: + await self._process_autopayments(db) await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) await self._check_trial_expiring_soon(db) await self._check_trial_channel_subscriptions(db) await self._check_expired_subscription_followups(db) - if settings.ENABLE_AUTOPAY: - await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -329,10 +330,23 @@ class MonitoringService: is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: - subscription.status = SubscriptionStatus.EXPIRED.value - await db.commit() - is_active = False - logger.info("📝 Статус подписки обновлен на 'expired'", subscription_id=subscription.id) + # Суточные подписки управляются DailySubscriptionService — не экспайрим + tariff = getattr(subscription, 'tariff', None) + is_active_daily = ( + tariff is not None + and getattr(tariff, 'is_daily', False) + and not getattr(subscription, 'is_daily_paused', False) + ) + if is_active_daily: + logger.debug( + 'update_remnawave_user: пропуск expire для суточной подписки', + subscription_id=subscription.id, + ) + else: + subscription.status = SubscriptionStatus.EXPIRED.value + await db.commit() + is_active = False + logger.info("📝 Статус подписки обновлен на 'expired'", subscription_id=subscription.id) if not self.subscription_service.is_configured: logger.warning( @@ -1004,6 +1018,9 @@ class MonitoringService: try: current_time = datetime.now(UTC) + # Берём ACTIVE + недавно EXPIRED (middleware или check_and_update могли + # экспайрить до того, как monitoring успел запустить autopay) + recently_expired_threshold = current_time - timedelta(hours=2) result = await db.execute( select(Subscription) .options( @@ -1015,7 +1032,15 @@ class MonitoringService: ) .where( and_( - Subscription.status == SubscriptionStatus.ACTIVE.value, + or_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + # Подписки, которые были экспайрены middleware/CRUD + # недавно (в пределах 2ч) — autopay может их восстановить + and_( + Subscription.status == SubscriptionStatus.EXPIRED.value, + Subscription.end_date >= recently_expired_threshold, + ), + ), Subscription.autopay_enabled == True, Subscription.is_trial == False, ) @@ -1055,17 +1080,44 @@ class MonitoringService: user_identifier = user.telegram_id or f'email:{user.id}' - # Правильный расчет стоимости продления с учетом всех параметров подписки - renewal_cost = await self.subscription_service.calculate_renewal_price(subscription, 30, db, user=user) - promo_discount_percent = self._get_user_promo_offer_discount_percent(user) - charge_amount = renewal_cost - promo_discount_value = 0 + # Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию + tariff = getattr(subscription, 'tariff', None) + if tariff: + autopay_period = tariff.get_shortest_period() or 30 + else: + autopay_period = 30 - if renewal_cost > 0 and promo_discount_percent > 0: - charge_amount, promo_discount_value = apply_percentage_discount( - renewal_cost, - promo_discount_percent, + try: + renewal_cost = await self.subscription_service.calculate_renewal_price( + subscription, + autopay_period, + db, + user=user, ) + except Exception as e: + logger.error( + 'Ошибка расчёта стоимости автопродления, пропускаем', + subscription_id=subscription.id, + user_id=user.id, + error=str(e), + ) + failed_count += 1 + continue + + if renewal_cost <= 0: + logger.warning( + 'Нулевая стоимость автопродления, пропускаем', + subscription_id=subscription.id, + user_id=user.id, + renewal_cost=renewal_cost, + ) + failed_count += 1 + continue + + # calculate_renewal_price уже включает promo_group + promo_offer скидки. + # Не применяем promo_offer повторно — только consume-им при успешной оплате. + charge_amount = renewal_cost + promo_discount_percent = self._get_user_promo_offer_discount_percent(user) autopay_key = f'autopay_{user.id}_{subscription.id}' if autopay_key in self._notified_users: @@ -1075,7 +1127,15 @@ class MonitoringService: success = await subtract_user_balance(db, user, charge_amount, 'Автопродление подписки') if success: - await extend_subscription(db, subscription, 30) + # extend_subscription сам обработает EXPIRED→ACTIVE переход + # (проверяет status + end_date для определения was_expired) + if subscription.status == SubscriptionStatus.EXPIRED.value: + logger.info( + '🔄 Autopay: продление EXPIRED подписки (восстановление)', + subscription_id=subscription.id, + user_id=user.id, + ) + await extend_subscription(db, subscription, autopay_period) await self.subscription_service.update_remnawave_user( db, subscription, @@ -1083,12 +1143,12 @@ class MonitoringService: reset_reason='автопродление подписки', ) - if promo_discount_value > 0: + if promo_discount_percent > 0: await self._consume_user_promo_offer_discount(db, user) # Send notification via appropriate channel if user.telegram_id and self.bot: - await self._send_autopay_success_notification(user, charge_amount, 30) + await self._send_autopay_success_notification(user, charge_amount, autopay_period) elif not user.telegram_id: # Email-only user - use notification delivery service await notification_delivery_service.notify_autopay_success( diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index f3fd57e6..854ddaa1 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -2678,10 +2678,20 @@ class RemnaWaveService: end_date_utc = self._local_to_utc(subscription.end_date) # Добавляем буфер 5 минут для защиты от race condition при продлении expiry_buffer = timedelta(minutes=5) + + # Суточные подписки управляются DailySubscriptionService — не экспайрим + tariff = getattr(subscription, 'tariff', None) + is_active_daily = ( + tariff is not None + and getattr(tariff, 'is_daily', False) + and not getattr(subscription, 'is_daily_paused', False) + ) + if ( end_date_utc + expiry_buffer <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value and not is_recently_updated_by_webhook(subscription) + and not is_active_daily ): time_since_expiry = current_time - end_date_utc logger.warning( diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index 0bbf8c7d..ff45297c 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -467,6 +467,25 @@ class RemnaWaveWebhookService: self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: if subscription: + # Суточные подписки управляются DailySubscriptionService. + # Remnawave может прислать user.expired если sync не дошёл (старый end_date), + # но локально подписка ещё жива — не экспайрим её. + tariff = getattr(subscription, 'tariff', None) + is_active_daily = ( + tariff is not None + and getattr(tariff, 'is_daily', False) + and not getattr(subscription, 'is_daily_paused', False) + ) + if is_active_daily: + logger.info( + 'Webhook: пропуск expire для суточной подписки (управляет DailySubscriptionService)', + subscription_id=subscription.id, + user_id=user.id, + ) + self._stamp_webhook_update(subscription) + await db.commit() + return + self._stamp_webhook_update(subscription) if subscription.status != SubscriptionStatus.EXPIRED.value: await expire_subscription(db, subscription) @@ -480,6 +499,23 @@ class RemnaWaveWebhookService: self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: if subscription: + # Суточные подписки управляются DailySubscriptionService — не деактивируем + tariff = getattr(subscription, 'tariff', None) + is_active_daily = ( + tariff is not None + and getattr(tariff, 'is_daily', False) + and not getattr(subscription, 'is_daily_paused', False) + ) + if is_active_daily: + logger.info( + 'Webhook: пропуск disabled для суточной подписки', + subscription_id=subscription.id, + user_id=user.id, + ) + self._stamp_webhook_update(subscription) + await db.commit() + return + self._stamp_webhook_update(subscription) if subscription.status != SubscriptionStatus.DISABLED.value: await deactivate_subscription(db, subscription) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 13c44f83..b10eca8a 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -152,10 +152,15 @@ async def _get_tariff_price_for_period( user: User, tariff_id: int, period_days: int, -) -> int | None: - """Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя.""" +) -> tuple[int, int] | None: + """Получает базовую цену тарифа и процент скидки (без применения). + + Returns: + (base_price, discount_percent) или None если тариф/период недоступен. + Скидка НЕ применяется — вызывающий код должен сначала добавить доп. устройства, + затем применить скидку к полной сумме (как в cabinet). + """ from app.database.crud.tariff import get_tariff_by_id - from app.utils.promo_offer import get_user_active_promo_discount_percent tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: @@ -174,17 +179,17 @@ async def _get_tariff_price_for_period( ) return None - # Получаем скидку пользователя + # Возвращаем только promo_group скидку. + # Promo_offer скидку вызывающий код должен применить отдельно (последовательно, как в cabinet). discount_percent = 0 - promo_group = getattr(user, 'promo_group', None) - if promo_group: - discount_percent = getattr(promo_group, 'server_discount_percent', 0) + if hasattr(user, 'get_promo_discount'): + discount_percent = user.get_promo_discount('period', period_days) + else: + promo_group = getattr(user, 'promo_group', None) + if promo_group and hasattr(promo_group, 'get_discount_percent'): + discount_percent = promo_group.get_discount_percent('period', period_days) - personal_discount = get_user_active_promo_discount_percent(user) - discount_percent = max(discount_percent, personal_discount) - - final_price = _apply_promo_discount_for_tariff(base_price, discount_percent) - return final_price + return (int(base_price), discount_percent) async def _prepare_auto_extend_context( @@ -227,8 +232,8 @@ async def _prepare_auto_extend_context( tariff_id = cart_data.get('tariff_id') if tariff_id: tariff_id = _safe_int(tariff_id) - price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days) - if price_kopeks is None: + tariff_result = await _get_tariff_price_for_period(db, user, tariff_id, period_days) + if tariff_result is None: # Тариф недоступен или период отсутствует - используем сохранённую цену как fallback price_kopeks = _safe_int( cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'), @@ -238,19 +243,37 @@ async def _prepare_auto_extend_context( tariff_id=tariff_id, price_kopeks=price_kopeks, ) - # Добавляем стоимость докупленных устройств при продлении того же тарифа - elif subscription.tariff_id == tariff_id: - from app.database.crud.tariff import get_tariff_by_id as _get_tariff + else: + base_price, discount_percent = tariff_result + price_kopeks = base_price - _tariff = await _get_tariff(db, tariff_id) - if _tariff: - extra_devices = max(0, (subscription.device_limit or 0) - (_tariff.device_limit or 0)) - if extra_devices > 0: - from app.utils.pricing_utils import calculate_months_from_days + # Добавляем стоимость докупленных устройств ДО применения скидки (как в cabinet) + if subscription.tariff_id == tariff_id: + from app.database.crud.tariff import get_tariff_by_id as _get_tariff - device_price_per_month = _tariff.device_price_kopeks or settings.PRICE_PER_DEVICE - months = calculate_months_from_days(period_days) - price_kopeks += extra_devices * device_price_per_month * months + _tariff = await _get_tariff(db, tariff_id) + if _tariff: + extra_devices = max(0, (subscription.device_limit or 0) - (_tariff.device_limit or 0)) + if extra_devices > 0: + from app.utils.pricing_utils import calculate_months_from_days + + device_price_per_month = ( + _tariff.device_price_kopeks + if _tariff.device_price_kopeks is not None + else settings.PRICE_PER_DEVICE + ) + months = calculate_months_from_days(period_days) + price_kopeks += extra_devices * device_price_per_month * months + + # Применяем promo_group скидку к полной сумме (база + доп. устройства) + price_kopeks = _apply_promo_discount_for_tariff(price_kopeks, discount_percent) + + # Применяем promo_offer скидку отдельно (последовательно, как в cabinet) + from app.utils.promo_offer import get_user_active_promo_discount_percent + + promo_offer_percent = get_user_active_promo_discount_percent(user) + if promo_offer_percent > 0: + price_kopeks = _apply_promo_discount_for_tariff(price_kopeks, promo_offer_percent) else: price_kopeks = _safe_int( cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'), @@ -602,7 +625,6 @@ async def _auto_purchase_tariff( tariff_id = _safe_int(cart_data.get('tariff_id')) period_days = _safe_int(cart_data.get('period_days')) - discount_percent = _safe_int(cart_data.get('discount_percent')) if not tariff_id or period_days <= 0: logger.warning( @@ -631,22 +653,39 @@ async def _auto_purchase_tariff( ) return False - final_price = _apply_promo_discount_for_tariff(base_price, discount_percent) + final_price = int(base_price) # Проверяем есть ли уже подписка (нужно до расчёта цены для учёта доп. устройств) existing_subscription = await get_subscription_by_user_id(db, user.id) - # Добавляем стоимость докупленных устройств при продлении того же тарифа + # Добавляем стоимость докупленных устройств ДО скидки (как в cabinet) if existing_subscription and existing_subscription.tariff_id == tariff_id: extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0)) if extra_devices > 0: from app.utils.pricing_utils import calculate_months_from_days - device_price_per_month = tariff.device_price_kopeks or settings.PRICE_PER_DEVICE + device_price_per_month = ( + tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE + ) months = calculate_months_from_days(period_days) extra_devices_cost = extra_devices * device_price_per_month * months final_price += extra_devices_cost + # Пересчитываем скидку из актуальных данных пользователя (не из stale корзины) + # Promo_group и promo_offer применяются последовательно (как в cabinet) + from app.utils.promo_offer import get_user_active_promo_discount_percent + + discount_percent = 0 + if hasattr(user, 'get_promo_discount'): + discount_percent = user.get_promo_discount('period', period_days) + + if discount_percent > 0: + final_price = _apply_promo_discount_for_tariff(final_price, discount_percent) + + promo_offer_percent = get_user_active_promo_discount_percent(user) + if promo_offer_percent > 0: + final_price = _apply_promo_discount_for_tariff(final_price, promo_offer_percent) + if user.balance_kopeks < final_price: logger.info( '🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)', diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 31629f51..6637cc6d 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -787,114 +787,171 @@ class SubscriptionService: try: from app.config import PERIOD_PRICES - # Use subscription's tariff price if available, fall back to global PERIOD_PRICES - tariff = getattr(subscription, 'tariff', None) - tariff_price = tariff.get_price_for_period(period_days) if tariff else None - base_price_original = tariff_price if tariff_price is not None else PERIOD_PRICES.get(period_days, 0) - if user is None: user = getattr(subscription, 'user', None) promo_group = promo_group or (user.get_primary_promo_group() if user else None) - servers_price, _ = await self.get_countries_price_by_uuids( - subscription.connected_squads, - db, - promo_group_id=promo_group.id if promo_group else None, - ) + tariff = getattr(subscription, 'tariff', None) + tariff_price = tariff.get_price_for_period(period_days) if tariff else None + is_tariff_pricing = tariff_price is not None - servers_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'servers', - period_days=period_days, - ) - servers_discount = servers_price * servers_discount_percent // 100 - discounted_servers_price = servers_price - servers_discount + if is_tariff_pricing: + # --- ТАРИФНЫЙ РЕЖИМ --- + # tariff.period_prices — итоговая цена тарифа (серверы, трафик включены). + # Сверху добавляем стоимость доп. устройств сверх лимита тарифа. + # Порядок: база + устройства → скидка на полную сумму (как в cabinet). + original_price = tariff_price - device_limit = subscription.device_limit - if device_limit is None: - if settings.is_devices_selection_enabled(): - device_limit = settings.DEFAULT_DEVICE_LIMIT - else: - forced_limit = settings.get_disabled_mode_device_limit() - if forced_limit is None: + # Доп. устройства сверх лимита тарифа + tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0 + device_price_per_unit = ( + tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE + ) + device_limit = ( + subscription.device_limit if subscription.device_limit is not None else tariff_device_limit + ) + extra_devices = max(0, device_limit - tariff_device_limit) + months = calculate_months_from_days(period_days) + devices_price = extra_devices * device_price_per_unit * months + original_price += devices_price + + # Скидка промогруппы на полную сумму (база + устройства) + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'period', + period_days=period_days, + ) + discount_total = original_price * period_discount_percent // 100 + total_price = original_price - discount_total + + # Promo-offer скидка (временная скидка, как в cabinet) + from app.utils.promo_offer import get_user_active_promo_discount_percent + + promo_offer_percent = get_user_active_promo_discount_percent(user) + promo_offer_discount = 0 + if promo_offer_percent > 0: + promo_offer_discount = total_price * promo_offer_percent // 100 + total_price = total_price - promo_offer_discount + + logger.debug( + '💰 Расчет стоимости продления (тариф) для подписки', + subscription_id=subscription.id, + tariff_name=tariff.name, + ) + base_log = f' 📅 Тариф «{tariff.name}», период {period_days} дней: {tariff_price / 100}₽' + if devices_price > 0: + base_log += f' + устройства ({extra_devices} сверх {tariff_device_limit}): {devices_price / 100}₽' + logger.debug(base_log) + if discount_total > 0: + logger.debug(f' 🏷️ Скидка промогруппы {period_discount_percent}%: -{discount_total / 100}₽') + if promo_offer_discount > 0: + logger.debug(f' 🎁 Promo-offer скидка {promo_offer_percent}%: -{promo_offer_discount / 100}₽') + logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) + + else: + # --- КЛАССИК РЕЖИМ --- + # base (PERIOD_PRICES) + серверы + трафик + устройства + base_price_original = PERIOD_PRICES.get(period_days, 0) + + servers_price, _ = await self.get_countries_price_by_uuids( + subscription.connected_squads, + db, + promo_group_id=promo_group.id if promo_group else None, + ) + + servers_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'servers', + period_days=period_days, + ) + servers_discount = servers_price * servers_discount_percent // 100 + discounted_servers_price = servers_price - servers_discount + + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): device_limit = settings.DEFAULT_DEVICE_LIMIT else: - device_limit = forced_limit + forced_limit = settings.get_disabled_mode_device_limit() + device_limit = forced_limit if forced_limit is not None else settings.DEFAULT_DEVICE_LIMIT - devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'devices', - period_days=period_days, - ) - devices_discount = devices_price * devices_discount_percent // 100 - discounted_devices_price = devices_price - devices_discount + devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE + devices_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'devices', + period_days=period_days, + ) + devices_discount = devices_price * devices_discount_percent // 100 + discounted_devices_price = devices_price - devices_discount - # В режиме fixed_with_topup при продлении используем фиксированный лимит - if settings.is_traffic_fixed(): - renewal_traffic_gb = settings.get_fixed_traffic_limit() - else: - renewal_traffic_gb = subscription.traffic_limit_gb - traffic_price = settings.get_traffic_price(renewal_traffic_gb) - traffic_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'traffic', - period_days=period_days, - ) - traffic_discount = traffic_price * traffic_discount_percent // 100 - discounted_traffic_price = traffic_price - traffic_discount + # Трафик: вычитаем purchased_traffic_gb чтобы не завышать тир + purchased_traffic = subscription.purchased_traffic_gb or 0 + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + elif purchased_traffic > 0: + base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic + renewal_traffic_gb = base_traffic if base_traffic > 0 else subscription.traffic_limit_gb + else: + renewal_traffic_gb = subscription.traffic_limit_gb - period_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'period', - period_days=period_days, - ) - base_discount_total = base_price_original * period_discount_percent // 100 - base_price = base_price_original - base_discount_total + traffic_price = settings.get_traffic_price(renewal_traffic_gb) + traffic_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'traffic', + period_days=period_days, + ) + traffic_discount = traffic_price * traffic_discount_percent // 100 + discounted_traffic_price = traffic_price - traffic_discount - total_price = base_price + discounted_servers_price + discounted_devices_price + discounted_traffic_price + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'period', + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total - logger.debug( - '💰 Расчет стоимости продления для подписки (по текущим ценам)', subscription_id=subscription.id - ) - base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' - if base_discount_total > 0: - base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' - logger.debug(base_log) - if servers_price > 0: - message = f' 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price / 100}₽' - if servers_discount > 0: - message += ( - f' (скидка {servers_discount_percent}%: -{servers_discount / 100}₽ от {servers_price / 100}₽)' - ) - logger.debug(message) - if devices_price > 0: - message = f' 📱 Устройства ({device_limit}): {discounted_devices_price / 100}₽' - if devices_discount > 0: - message += ( - f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽ от {devices_price / 100}₽)' - ) - logger.debug(message) - if traffic_price > 0: - message = f' 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price / 100}₽' - if traffic_discount > 0: - message += ( - f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽ от {traffic_price / 100}₽)' - ) - logger.debug(message) - logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) + total_price = ( + base_price + discounted_servers_price + discounted_devices_price + discounted_traffic_price + ) + + logger.debug( + '💰 Расчет стоимости продления (классик) для подписки', + subscription_id=subscription.id, + ) + base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' + if base_discount_total > 0: + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%)' + logger.debug(base_log) + if servers_price > 0: + message = f' 🌍 Серверы ({len(subscription.connected_squads)}): {discounted_servers_price / 100}₽' + if servers_discount > 0: + message += f' (скидка {servers_discount_percent}%: -{servers_discount / 100}₽)' + logger.debug(message) + if devices_price > 0: + message = f' 📱 Устройства ({device_limit}): {discounted_devices_price / 100}₽' + if devices_discount > 0: + message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽)' + logger.debug(message) + if traffic_price > 0: + message = f' 📊 Трафик ({renewal_traffic_gb} ГБ): {discounted_traffic_price / 100}₽' + if traffic_discount > 0: + message += f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽)' + logger.debug(message) + logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) return total_price except Exception as e: - logger.error('Ошибка расчета стоимости продления', error=e) - from app.config import PERIOD_PRICES - - return PERIOD_PRICES.get(period_days, 0) + logger.error('Ошибка расчета стоимости продления', error=e, exc_info=True) + # Не возвращаем 0 — это приведёт к бесплатному продлению. + # Пробрасываем ошибку, чтобы вызывающий код решал что делать. + raise async def validate_and_clean_subscription(self, db: AsyncSession, subscription: Subscription, user: User) -> bool: try: @@ -1132,114 +1189,172 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) - # Use subscription's tariff price if available, fall back to global PERIOD_PRICES - tariff = getattr(subscription, 'tariff', None) - tariff_price = tariff.get_price_for_period(period_days) if tariff else None - base_price_original = tariff_price if tariff_price is not None else PERIOD_PRICES.get(period_days, 0) - if user is None: user = getattr(subscription, 'user', None) promo_group = promo_group or (user.get_primary_promo_group() if user else None) - servers_price_per_month, _ = await self.get_countries_price_by_uuids( - subscription.connected_squads, - db, - promo_group_id=promo_group.id if promo_group else None, - ) - servers_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'servers', - period_days=period_days, - ) - servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 - discounted_servers_per_month = servers_price_per_month - servers_discount_per_month - total_servers_price = discounted_servers_per_month * months_in_period + tariff = getattr(subscription, 'tariff', None) + tariff_price = tariff.get_price_for_period(period_days) if tariff else None + is_tariff_pricing = tariff_price is not None - device_limit = subscription.device_limit - if device_limit is None: - if settings.is_devices_selection_enabled(): - device_limit = settings.DEFAULT_DEVICE_LIMIT - else: - forced_limit = settings.get_disabled_mode_device_limit() - if forced_limit is None: + if is_tariff_pricing: + # --- ТАРИФНЫЙ РЕЖИМ --- + # Порядок: база + устройства → скидка на полную сумму (как в cabinet). + original_price = tariff_price + + # Доп. устройства сверх лимита тарифа + tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0 + device_price_per_unit = ( + tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE + ) + device_limit = ( + subscription.device_limit if subscription.device_limit is not None else tariff_device_limit + ) + extra_devices = max(0, device_limit - tariff_device_limit) + devices_price_total = extra_devices * device_price_per_unit * months_in_period + original_price += devices_price_total + + # Скидка промогруппы на полную сумму (база + устройства) + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'period', + period_days=period_days, + ) + discount_total = original_price * period_discount_percent // 100 + total_price = original_price - discount_total + + # Promo-offer скидка (временная скидка, как в cabinet) + from app.utils.promo_offer import get_user_active_promo_discount_percent + + promo_offer_percent = get_user_active_promo_discount_percent(user) + promo_offer_discount = 0 + if promo_offer_percent > 0: + promo_offer_discount = total_price * promo_offer_percent // 100 + total_price = total_price - promo_offer_discount + + logger.debug( + '💰 Расчет стоимости продления (тариф) на дней ( мес)', + subscription_id=subscription.id, + period_days=period_days, + months_in_period=months_in_period, + tariff_name=tariff.name, + ) + base_log = f' 📅 Тариф «{tariff.name}», период {period_days} дней: {tariff_price / 100}₽' + if devices_price_total > 0: + base_log += ( + f' + устройства ({extra_devices} сверх {tariff_device_limit}): {devices_price_total / 100}₽' + ) + logger.debug(base_log) + if discount_total > 0: + logger.debug(f' 🏷️ Скидка промогруппы {period_discount_percent}%: -{discount_total / 100}₽') + if promo_offer_discount > 0: + logger.debug(f' 🎁 Promo-offer скидка {promo_offer_percent}%: -{promo_offer_discount / 100}₽') + logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) + + else: + # --- КЛАССИК РЕЖИМ --- + base_price_original = PERIOD_PRICES.get(period_days, 0) + + servers_price_per_month, _ = await self.get_countries_price_by_uuids( + subscription.connected_squads, + db, + promo_group_id=promo_group.id if promo_group else None, + ) + servers_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'servers', + period_days=period_days, + ) + servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 + discounted_servers_per_month = servers_price_per_month - servers_discount_per_month + total_servers_price = discounted_servers_per_month * months_in_period + + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): device_limit = settings.DEFAULT_DEVICE_LIMIT else: - device_limit = forced_limit + forced_limit = settings.get_disabled_mode_device_limit() + device_limit = forced_limit if forced_limit is not None else settings.DEFAULT_DEVICE_LIMIT - additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'devices', - period_days=period_days, - ) - devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 - discounted_devices_per_month = devices_price_per_month - devices_discount_per_month - total_devices_price = discounted_devices_per_month * months_in_period + additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE + devices_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'devices', + period_days=period_days, + ) + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_per_month = devices_price_per_month - devices_discount_per_month + total_devices_price = discounted_devices_per_month * months_in_period - # В режиме fixed_with_topup при продлении используем фиксированный лимит - if settings.is_traffic_fixed(): - renewal_traffic_gb = settings.get_fixed_traffic_limit() - else: - renewal_traffic_gb = subscription.traffic_limit_gb - traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) - traffic_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'traffic', - period_days=period_days, - ) - traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 - discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month - total_traffic_price = discounted_traffic_per_month * months_in_period + # Трафик: вычитаем purchased_traffic_gb чтобы не завышать тир + purchased_traffic = subscription.purchased_traffic_gb or 0 + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + elif purchased_traffic > 0: + base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic + renewal_traffic_gb = base_traffic if base_traffic > 0 else subscription.traffic_limit_gb + else: + renewal_traffic_gb = subscription.traffic_limit_gb - period_discount_percent = _resolve_discount_percent( - user, - promo_group, - 'period', - period_days=period_days, - ) - base_discount_total = base_price_original * period_discount_percent // 100 - base_price = base_price_original - base_discount_total + traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) + traffic_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'traffic', + period_days=period_days, + ) + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_price = discounted_traffic_per_month * months_in_period - total_price = base_price + total_servers_price + total_devices_price + total_traffic_price + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + 'period', + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total - logger.debug( - '💰 Расчет стоимости продления подписки на дней ( мес)', - subscription_id=subscription.id, - period_days=period_days, - months_in_period=months_in_period, - ) - base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' - if base_discount_total > 0: - base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' - logger.debug(base_log) - if total_servers_price > 0: - message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес x {months_in_period} = {total_servers_price / 100}₽' - if servers_discount_per_month > 0: - message += f' (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period / 100}₽)' - logger.debug(message) - if total_devices_price > 0: - message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}₽' - if devices_discount_per_month > 0: - message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' - logger.debug(message) - if total_traffic_price > 0: - message = f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}₽' - if traffic_discount_per_month > 0: - message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' - logger.debug(message) - logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) + total_price = base_price + total_servers_price + total_devices_price + total_traffic_price + + logger.debug( + '💰 Расчет стоимости продления (классик) на дней ( мес)', + subscription_id=subscription.id, + period_days=period_days, + months_in_period=months_in_period, + ) + base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' + if base_discount_total > 0: + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%)' + logger.debug(base_log) + if total_servers_price > 0: + message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес x {months_in_period} = {total_servers_price / 100}₽' + if servers_discount_per_month > 0: + message += f' (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period / 100}₽)' + logger.debug(message) + if total_devices_price > 0: + message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}₽' + if devices_discount_per_month > 0: + message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' + logger.debug(message) + if total_traffic_price > 0: + message = f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}₽' + if traffic_discount_per_month > 0: + message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' + logger.debug(message) + logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) return total_price except Exception as e: - logger.error('Ошибка расчета стоимости продления', error=e) - from app.config import PERIOD_PRICES - - return PERIOD_PRICES.get(period_days, 0) + logger.error('Ошибка расчета стоимости продления (with_months)', error=e, exc_info=True) + raise async def calculate_addon_price_with_remaining_period( self, diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 0d39946d..434414c1 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import ( ENV_OVERRIDE_KEYS, Settings, + clear_db_period_prices, refresh_period_prices, refresh_traffic_prices, settings, @@ -1458,6 +1459,11 @@ class BotConfigurationService: await cls._sync_default_web_api_token() + # После загрузки всех overrides (включая SALES_MODE) — пересчитать цены, + # т.к. ensure_tariffs_synced мог загрузить тарифные цены до того как + # SALES_MODE=classic был применён из system_settings + refresh_period_prices() + @classmethod async def reload(cls) -> None: cls._overrides_raw.clear() @@ -1570,6 +1576,11 @@ class BotConfigurationService: if key in {'WEB_API_DEFAULT_TOKEN', 'WEB_API_DEFAULT_TOKEN_NAME'}: await cls._sync_default_web_api_token() + if key == 'SALES_MODE' and settings.is_tariffs_mode(): + from app.database.crud.tariff import load_period_prices_from_db + + await load_period_prices_from_db(db) + @classmethod async def reset_value( cls, @@ -1592,6 +1603,11 @@ class BotConfigurationService: if key in {'WEB_API_DEFAULT_TOKEN', 'WEB_API_DEFAULT_TOKEN_NAME'}: await cls._sync_default_web_api_token() + if key == 'SALES_MODE' and settings.is_tariffs_mode(): + from app.database.crud.tariff import load_period_prices_from_db + + await load_period_prices_from_db(db) + @classmethod def _apply_to_settings(cls, key: str, value: Any) -> None: if cls._is_env_override(key): @@ -1599,7 +1615,11 @@ class BotConfigurationService: return try: setattr(settings, key, value) - if key in { + if key == 'SALES_MODE': + if settings.is_classic_mode(): + clear_db_period_prices() + refresh_period_prices() + elif key in { 'PRICE_14_DAYS', 'PRICE_30_DAYS', 'PRICE_60_DAYS', diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index b3a472b1..85384c66 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5233,32 +5233,44 @@ async def submit_subscription_renewal_endpoint( # Рассчитываем цену из тарифа original_price_kopeks = tariff.period_prices.get(str(period_days), tariff.period_prices.get(period_days, 0)) - # Применяем скидку промогруппы - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - discount_percent = 0 - if promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - if int(k) == period_days: - discount_percent = max(0, min(100, int(v))) - break - except (TypeError, ValueError): - pass + # Добавляем стоимость докупленных устройств сверх тарифа (ДО скидки, как в cabinet) + tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0 + sub_device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit + extra_devices = max(0, sub_device_limit - tariff_device_limit) + if extra_devices > 0: + from app.utils.pricing_utils import calculate_months_from_days + device_price = ( + tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE + ) + months = calculate_months_from_days(period_days) + original_price_kopeks += extra_devices * device_price * months + + # Применяем скидку промогруппы (к полной сумме: тариф + доп. устройства) + discount_percent = 0 + if hasattr(user, 'get_promo_discount'): + discount_percent = user.get_promo_discount('period', period_days) + + final_total = original_price_kopeks if discount_percent > 0: final_total = int(original_price_kopeks * (100 - discount_percent) / 100) - else: - final_total = original_price_kopeks + + # Применяем promo_offer скидку (временная скидка, как в cabinet) + promo_offer_discount_percent = get_user_active_promo_discount_percent(user) + if promo_offer_discount_percent > 0: + promo_offer_discount_value = final_total * promo_offer_discount_percent // 100 + final_total = final_total - promo_offer_discount_value + + # Комбинированный процент скидки для отображения + combined_discount_percent = discount_percent + if promo_offer_discount_percent > 0 and original_price_kopeks > 0: + total_discount = original_price_kopeks - final_total + combined_discount_percent = int(total_discount * 100 / original_price_kopeks) tariff_pricing = { 'period_days': period_days, 'original_price_kopeks': original_price_kopeks, - 'discount_percent': discount_percent, + 'discount_percent': combined_discount_percent, 'final_total': final_total, 'tariff_id': tariff.id, } diff --git a/uv.lock b/uv.lock index e5bc451d..deb74506 100644 --- a/uv.lock +++ b/uv.lock @@ -1115,7 +1115,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.14.0" +version = "3.18.0" source = { virtual = "." } dependencies = [ { name = "aiogram" },