Files
remnawave-bedolaga-telegram…/app/services/daily_subscription_service.py
T
Fringg 8d3cd50098 refactor: централизация всех расчётов цен в PricingEngine
- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price()
- Мигрирован compute_simple_subscription_price на делегацию в PricingEngine
- Мигрирован handle_custom_confirm на calculate_tariff_purchase_price()
- Мигрированы daily confirm handlers (confirm_daily_tariff_purchase,
  confirm_daily_tariff_switch, confirm_instant_switch daily path)
- Мигрирован gift.py на calculate_tariff_purchase_price()
- Мигрированы FSM cache prices (select_period, select_devices, toggle_country)
- Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix)
- Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic
- Исправлено двойное применение promo-offer в simple_subscription (критический баг)
- Унифицирован daily price display (group+offer) на всех 6 поверхностях
- PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg
- PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount
- Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user)
- Miniapp period_discounts: исправлен доступ через get_discount_percent()
2026-03-16 03:10:22 +03:00

655 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервис для автоматического списания суточных подписок.
Проверяет подписки с суточным тарифом и списывает плату раз в сутки.
Также сбрасывает докупленный трафик по истечении 30 дней.
"""
import asyncio
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from sqlalchemy import select
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, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.notification_delivery_service import (
NotificationType,
notification_delivery_service,
)
logger = structlog.get_logger(__name__)
class DailySubscriptionService:
"""
Сервис автоматического списания для суточных подписок.
"""
def __init__(self):
self._running = False
self._bot: Bot | None = None
self._check_interval_minutes = 30 # Проверка каждые 30 минут
def set_bot(self, bot: Bot):
"""Устанавливает бота для отправки уведомлений."""
self._bot = bot
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис суточных подписок."""
return getattr(settings, 'DAILY_SUBSCRIPTIONS_ENABLED', True)
def get_check_interval_minutes(self) -> int:
"""Возвращает интервал проверки в минутах."""
return getattr(settings, 'DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES', 30)
async def process_daily_charges(self) -> dict:
"""
Обрабатывает суточные списания.
Returns:
dict: Статистика обработки
"""
stats = {
'checked': 0,
'charged': 0,
'suspended': 0,
'errors': 0,
}
try:
async with AsyncSessionLocal() as db:
try:
subscriptions = await get_daily_subscriptions_for_charge(db)
stats['checked'] = len(subscriptions)
for subscription in subscriptions:
try:
result = await self._process_single_charge(db, subscription)
if result == 'charged':
stats['charged'] += 1
elif result == 'suspended':
stats['suspended'] += 1
elif result == 'error':
stats['errors'] += 1
except Exception as e:
logger.error(
'Ошибка обработки суточной подписки',
subscription_id=subscription.id,
error=e,
exc_info=True,
)
stats['errors'] += 1
await db.commit()
except Exception as e:
logger.error('Ошибка при обработке подписок', error=e, exc_info=True)
await db.rollback()
except Exception as e:
logger.error('Ошибка при получении подписок для списания', error=e, exc_info=True)
return stats
async def _process_single_charge(self, db, subscription) -> str:
"""
Обрабатывает списание для одной подписки.
Returns:
str: "charged", "suspended", "error", "skipped"
"""
user = subscription.user
if not user:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.warning('Пользователь не найден для подписки', subscription_id=subscription.id)
return 'error'
tariff = subscription.tariff
if not tariff:
logger.warning('Тариф не найден для подписки', subscription_id=subscription.id)
return 'error'
raw_daily_price = tariff.daily_price_kopeks
if raw_daily_price <= 0:
logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id)
return 'error'
# Lock user row to prevent TOCTOU between discount read and balance charge
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply group discount to daily price (consistent with PricingEngine._calculate_switch_to_daily)
from app.services.pricing_engine import PricingEngine
promo_group = PricingEngine.resolve_promo_group(user)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = (
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# Проверяем баланс
if user.balance_kopeks < daily_price:
# Недостаточно средств - приостанавливаем подписку
await suspend_daily_subscription_insufficient_balance(db, subscription)
# Уведомляем пользователя
if self._bot:
await self._notify_insufficient_balance(user, subscription, daily_price)
logger.info(
'Подписка приостановлена: недостаточно средств (баланс: требуется: )',
subscription_id=subscription.id,
balance_kopeks=user.balance_kopeks,
daily_price=daily_price,
)
return 'suspended'
# Списываем средства
description = f'Суточная оплата тарифа «{tariff.name}»'
try:
deducted = await subtract_user_balance(
db,
user,
daily_price,
description,
mark_as_paid_subscription=True,
)
if not deducted:
logger.warning('Не удалось списать средства для подписки', subscription_id=subscription.id)
return 'error'
# Создаём транзакцию
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
payment_method=PaymentMethod.BALANCE,
)
# Обновляем время последнего списания и продлеваем подписку
old_end_date = subscription.end_date
subscription = await update_daily_charge_time(db, subscription)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(
'✅ Суточное списание: подписка сумма коп., пользователь',
subscription_id=subscription.id,
daily_price=daily_price,
user_id_display=user_id_display,
)
# Синхронизируем с Remnawave (обновляем срок подписки)
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.warning('Не удалось обновить Remnawave', error=e)
# Отправляем уведомление администраторам
try:
from app.services.subscription_renewal_service import with_admin_notification_service
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
subscription,
transaction,
1, # 1 день для суточного тарифа
old_end_date,
new_end_date=subscription.end_date,
balance_after=user.balance_kopeks,
)
)
except Exception as exc:
logger.warning('Не удалось отправить админ-уведомление о суточном списании', user_id=user.id, exc=exc)
# Уведомляем пользователя
if self._bot:
await self._notify_daily_charge(user, subscription, daily_price)
return 'charged'
except Exception as e:
logger.error(
'Ошибка при списании средств для подписки', subscription_id=subscription.id, error=e, exc_info=True
)
return 'error'
async def _notify_daily_charge(self, user, subscription, amount_kopeks: int):
"""Уведомляет пользователя о суточном списании."""
get_texts(getattr(user, 'language', 'ru'))
amount_rubles = amount_kopeks / 100
balance_rubles = user.balance_kopeks / 100
message = (
f'💳 <b>Суточное списание</b>\n\n'
f'Списано: {amount_rubles:.2f}\n'
f'Остаток баланса: {balance_rubles:.2f}\n\n'
f'Следующее списание через 24 часа.'
)
# Use unified notification delivery service
try:
await notification_delivery_service.notify_daily_debit(
user=user,
amount_kopeks=amount_kopeks,
new_balance_kopeks=user.balance_kopeks,
bot=self._bot,
telegram_message=message,
)
except Exception as e:
logger.warning('Не удалось отправить уведомление о списании', error=e)
async def _notify_insufficient_balance(self, user, subscription, required_amount: int):
"""Уведомляет пользователя о недостатке средств."""
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
get_texts(getattr(user, 'language', 'ru'))
required_rubles = required_amount / 100
balance_rubles = user.balance_kopeks / 100
message = (
f'⚠️ <b>Подписка приостановлена</b>\n\n'
f'Недостаточно средств для суточной оплаты.\n\n'
f'Требуется: {required_rubles:.2f}\n'
f'Баланс: {balance_rubles:.2f}\n\n'
f'Пополните баланс, чтобы возобновить подписку.'
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='menu_balance')],
[InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')],
]
)
# Use unified notification delivery service
context = {
'required_amount': f'{required_rubles:.2f}',
'current_balance': f'{balance_rubles:.2f}',
}
try:
await notification_delivery_service.send_notification(
user=user,
notification_type=NotificationType.DAILY_INSUFFICIENT_FUNDS,
context=context,
bot=self._bot,
telegram_message=message,
telegram_markup=keyboard,
)
except Exception as e:
logger.warning('Не удалось отправить уведомление о недостатке средств', error=e)
async def process_traffic_resets(self) -> dict:
"""
Сбрасывает докупленный трафик у подписок, у которых истёк срок.
Returns:
dict: Статистика обработки
"""
stats = {
'checked': 0,
'reset': 0,
'errors': 0,
}
from app.database.models import TrafficPurchase
try:
async with AsyncSessionLocal() as db:
try:
# Находим все истекшие докупки
now = datetime.now(UTC)
query = select(TrafficPurchase).where(TrafficPurchase.expires_at <= now)
result = await db.execute(query)
expired_purchases = result.scalars().all()
stats['checked'] = len(expired_purchases)
# Группируем по подпискам для обновления
subscriptions_to_update = {}
for purchase in expired_purchases:
if purchase.subscription_id not in subscriptions_to_update:
subscriptions_to_update[purchase.subscription_id] = []
subscriptions_to_update[purchase.subscription_id].append(purchase)
# Удаляем истекшие докупки и обновляем подписки
for subscription_id, purchases in subscriptions_to_update.items():
try:
await self._reset_subscription_traffic(db, subscription_id, purchases)
stats['reset'] += len(purchases)
except Exception as e:
logger.error(
'Ошибка сброса трафика подписки',
subscription_id=subscription_id,
error=e,
exc_info=True,
)
stats['errors'] += 1
await db.commit()
except Exception as e:
logger.error('Ошибка при обработке сброса трафика', error=e, exc_info=True)
await db.rollback()
except Exception as e:
logger.error('Ошибка при получении подписок для сброса трафика', error=e, exc_info=True)
return stats
async def _reset_subscription_traffic(self, db: AsyncSession, subscription_id: int, expired_purchases: list):
"""Сбрасывает истекшие докупки трафика у подписки."""
from app.database.models import TrafficPurchase
# Получаем подписку
subscription_query = select(Subscription).where(Subscription.id == subscription_id)
subscription_result = await db.execute(subscription_query)
subscription = subscription_result.scalar_one_or_none()
if not subscription:
return
# Считаем сколько ГБ нужно убрать
total_expired_gb = sum(p.traffic_gb for p in expired_purchases)
old_limit = subscription.traffic_limit_gb
old_purchased = subscription.purchased_traffic_gb or 0
# КРИТИЧЕСКАЯ ПРОВЕРКА: защита от некорректных данных
if total_expired_gb > old_purchased:
logger.error(
'⚠️ ОШИБКА ДАННЫХ: подписка истекает ГБ, но purchased_traffic_gb ГБ. Сбрасываем только ГБ.',
subscription_id=subscription.id,
total_expired_gb=total_expired_gb,
old_purchased=old_purchased,
old_purchased_2=old_purchased,
)
total_expired_gb = old_purchased
# Рассчитываем базовый лимит тарифа (без докупок)
base_limit = old_limit - old_purchased
# Получаем базовый лимит из тарифа для проверки
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
tariff_base_limit = tariff.traffic_limit_gb or 0
# Проверяем, что базовый лимит не отрицательный
if base_limit < 0:
logger.warning(
'⚠️ Базовый лимит отрицательный для подписки ГБ. Используем лимит из тарифа: ГБ',
subscription_id=subscription.id,
base_limit=base_limit,
tariff_base_limit=tariff_base_limit,
)
base_limit = tariff_base_limit
# Защита от отрицательного базового лимита
base_limit = max(0, base_limit)
# Удаляем истекшие записи
for purchase in expired_purchases:
await db.delete(purchase)
# Рассчитываем новый лимит
new_purchased = old_purchased - total_expired_gb
new_limit = base_limit + new_purchased
# Двойная защита: новый лимит не может быть меньше базового
if new_limit < base_limit:
logger.error(
'⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ( ГБ) меньше базового ( ГБ). Устанавливаем базовый лимит.',
new_limit=new_limit,
base_limit=base_limit,
)
new_limit = base_limit
new_purchased = 0
# Обновляем подписку
subscription.traffic_limit_gb = max(0, new_limit)
subscription.purchased_traffic_gb = max(0, new_purchased)
# Проверяем, остались ли активные докупки
now = datetime.now(UTC)
remaining_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription_id)
.where(TrafficPurchase.expires_at > now)
)
remaining_result = await db.execute(remaining_query)
remaining_purchases = remaining_result.scalars().all()
if not remaining_purchases:
# Нет больше активных докупок - сбрасываем дату
subscription.traffic_reset_at = None
else:
# Устанавливаем дату сброса по ближайшей истекающей докупке
next_expiry = min(p.expires_at for p in remaining_purchases)
subscription.traffic_reset_at = next_expiry
subscription.updated_at = datetime.now(UTC)
await db.commit()
logger.info(
'🔄 Сброс истекших докупок: подписка было ГБ (базовый: ГБ, докуплено: ГБ), стало ГБ (базовый: ГБ, докуплено: ГБ), убрано ГБ из покупок',
subscription_id=subscription.id,
old_limit=old_limit,
base_limit=base_limit,
old_purchased=old_purchased,
traffic_limit_gb=subscription.traffic_limit_gb,
base_limit_2=base_limit,
new_purchased=new_purchased,
total_expired_gb=total_expired_gb,
expired_purchases_count=len(expired_purchases),
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.warning('Не удалось синхронизировать с RemnaWave после сброса трафика', error=e)
# Уведомляем пользователя
if self._bot and subscription.user_id:
user = await get_user_by_id(db, subscription.user_id)
if user:
await self._notify_traffic_reset(user, subscription, total_expired_gb)
async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int):
"""Уведомляет пользователя о сбросе докупленного трафика."""
message = (
f'️ <b>Сброс докупленного трафика</b>\n\n'
f'Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, '
f'так как прошло 30 дней с момента первой докупки.\n\n'
f'Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n'
f'Вы можете докупить трафик снова в любое время.'
)
context = {
'reset_gb': reset_gb,
'current_limit_gb': subscription.traffic_limit_gb,
}
# Use unified notification delivery service
try:
await notification_delivery_service.send_notification(
user=user,
notification_type=NotificationType.TRAFFIC_RESET,
context=context,
bot=self._bot,
telegram_message=message,
)
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
interval_minutes = self.get_check_interval_minutes()
logger.info('🔄 Запуск сервиса суточных подписок (интервал: мин)', interval_minutes=interval_minutes)
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()
if stats['charged'] > 0 or stats['suspended'] > 0:
logger.info(
'📊 Суточные списания: проверено=, списано=, приостановлено=, ошибок',
stats=stats['checked'],
stats_2=stats['charged'],
stats_3=stats['suspended'],
stats_4=stats['errors'],
)
# Обработка сброса докупленного трафика
traffic_stats = await self.process_traffic_resets()
if traffic_stats['reset'] > 0:
logger.info(
'📊 Сброс трафика: проверено=, сброшено=, ошибок',
traffic_stats=traffic_stats['checked'],
traffic_stats_2=traffic_stats['reset'],
traffic_stats_3=traffic_stats['errors'],
)
except Exception as e:
logger.error('Ошибка в цикле проверки суточных подписок', error=e, exc_info=True)
await asyncio.sleep(interval_minutes * 60)
def stop_monitoring(self):
"""Останавливает периодическую проверку."""
self._running = False
logger.info('⏹️ Сервис суточных подписок остановлен')
# Глобальный экземпляр сервиса
daily_subscription_service = DailySubscriptionService()
__all__ = ['DailySubscriptionService', 'daily_subscription_service']