Merge pull request #2662 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-04 06:14:02 +03:00
committed by GitHub
23 changed files with 1021 additions and 366 deletions
+4
View File
@@ -16,6 +16,10 @@ __pycache__/
.pytest_cache/
.coverage
htmlcov/
.venv/
tests/
.mypy_cache/
.ruff_cache/
# Environment files
.env
+15 -16
View File
@@ -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"]
-2
View File
@@ -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
+19 -3
View File
@@ -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)
+86 -31
View File
@@ -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
+11 -4
View File
@@ -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()}
)
+1
View File
@@ -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)
+105 -18
View File
@@ -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,
+5
View File
@@ -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)
+5 -2
View File
@@ -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 = ['📋 <b>Список кампаний</b>\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} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\n'
+12 -16
View File
@@ -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:
+34 -1
View File
@@ -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,
+5
View File
@@ -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
+78 -19
View File
@@ -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 {}
+96 -1
View File
@@ -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()
+80 -20
View File
@@ -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(
+10
View File
@@ -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(
+36
View File
@@ -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)
@@ -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(
'🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)',
+298 -183
View File
@@ -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,
+21 -1
View File
@@ -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',
+31 -19
View File
@@ -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,
}
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.14.0"
version = "3.18.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },