@@ -16,6 +16,10 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.venv/
|
||||
tests/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
+15
-16
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
'🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user