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" },