Files
remnawave-bedolaga-telegram…/app/services/recurrent_payment_service.py
T
Fringg 7dd67e36b3 feat: add tariff identification to all notifications for multi-tariff mode
When MULTI_TARIFF_ENABLED=true users can have multiple subscriptions,
so notifications must identify which tariff they relate to.

- Webhook notifications: _notify_user auto-injects tariff_label from
  subscription.tariff.name into all 16 webhook notification strings
- Monitoring service: tariff labels in expired, expiring, trial ending,
  follow-up waves, autopay success/failed notifications
- Daily subscription: tariff in insufficient balance notification
- Recurrent payments: tariff in card autopay success/failed
- Auto-purchase: tariff in all 4 auto-purchase notification paths,
  with pre-captured names to avoid MissingGreenlet after db.commit()
- Channel checker: plural form for multi-subscription deactivation
- Payment providers: tariff in YooKassa and Stars activation messages
- Admin: tariff in bulk expiry reminder
- Localization: {tariff_label} in 20 notification keys across all 5
  locales (ru, en, ua, zh, fa) + _MULTI channel keys
- Fix: selectinload(Subscription.tariff) in trial expiring query
- Fix: capture tariff_name before expire_subscription to prevent
  MissingGreenlet from db.refresh() expiring ORM relationships
2026-03-28 19:12:34 +03:00

408 lines
16 KiB
Python

"""Сервис рекуррентных автоплатежей через YooKassa.
Находит подписки с autopay, у которых недостаточно баланса для продления,
и пополняет баланс с сохранённой карты. Существующий autopay в
monitoring_service затем спишет баланс и продлит подписку.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import (
Subscription,
SubscriptionStatus,
User,
UserPromoGroup,
)
logger = structlog.get_logger(__name__)
@dataclass
class _DailyGuard:
"""Защита от повторной обработки подписок в рамках одного дня."""
date: str = ''
processed: set[str] = field(default_factory=set)
def reset_if_new_day(self) -> None:
today = datetime.now(UTC).strftime('%Y-%m-%d')
if today != self.date:
self.processed = set()
self.date = today
def is_processed(self, key: str) -> bool:
return key in self.processed
def mark_processed(self, key: str) -> None:
self.processed.add(key)
_daily_guard = _DailyGuard()
def _build_extend_keyboard(texts, subscription_id: int | None = None) -> InlineKeyboardMarkup:
"""Клавиатура с кнопкой продления подписки для уведомлений."""
extend_callback = (
f'se:{subscription_id}' if settings.is_multi_tariff_enabled() and subscription_id else 'subscription_extend'
)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
callback_data=extend_callback,
)
],
]
)
async def process_recurrent_payments(db: AsyncSession, bot: Bot | None = None) -> dict:
"""
Основная функция: находит подписки, которым скоро нужно продление,
у которых недостаточно баланса, и пополняет баланс с сохранённой карты.
Args:
db: Сессия БД из вызывающего кода (_monitoring_cycle)
bot: Экземпляр бота для уведомлений
Returns:
dict: Статистика обработки
"""
if not settings.YOOKASSA_RECURRENT_ENABLED:
return {'skipped': True, 'reason': 'recurrent_disabled'}
if not settings.YOOKASSA_ENABLED:
return {'skipped': True, 'reason': 'yookassa_disabled'}
if not settings.ENABLE_AUTOPAY:
return {'skipped': True, 'reason': 'autopay_disabled'}
_daily_guard.reset_if_new_day()
stats = {
'checked': 0,
'payments_created': 0,
'insufficient_no_card': 0,
'all_cards_failed': 0,
'already_processed': 0,
'errors': 0,
}
# Создаём сервисы один раз для всех подписок
from app.services.payment_service import PaymentService
from app.services.subscription_service import SubscriptionService
payment_service = PaymentService()
subscription_service = SubscriptionService()
try:
subscriptions = await _find_subscriptions_needing_topup(db)
stats['checked'] = len(subscriptions)
for subscription in subscriptions:
user = subscription.user
if not user:
continue
guard_key = f'{user.id}_{subscription.id}'
if _daily_guard.is_processed(guard_key):
stats['already_processed'] += 1
continue
try:
result = await _process_single_subscription(
db,
subscription,
user,
bot,
payment_service,
subscription_service,
)
if result == 'created':
stats['payments_created'] += 1
_daily_guard.mark_processed(guard_key)
elif result == 'no_card':
stats['insufficient_no_card'] += 1
_daily_guard.mark_processed(guard_key)
elif result == 'all_cards_failed':
stats['all_cards_failed'] += 1
_daily_guard.mark_processed(guard_key)
elif result == 'skipped':
stats['already_processed'] += 1
except Exception as e:
stats['errors'] += 1
logger.error(
'Ошибка обработки рекуррентного платежа',
subscription_id=subscription.id,
user_id=user.id,
error=e,
exc_info=True,
)
except Exception as e:
logger.error('Ошибка получения подписок для рекуррентных платежей', error=e, exc_info=True)
stats['errors'] += 1
if stats['payments_created'] > 0 or stats['errors'] > 0:
logger.info('Рекуррентные платежи: итоги', **stats)
return stats
async def _find_subscriptions_needing_topup(db: AsyncSession) -> list:
"""Находит подписки с autopay, которым скоро нужно продление."""
current_time = datetime.now(UTC)
max_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
# Максимальный горизонт проверки
check_horizon = current_time + timedelta(days=max_days_before + 1)
recently_expired_threshold = current_time - timedelta(hours=48)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
),
selectinload(Subscription.tariff),
)
.where(
and_(
or_(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= check_horizon,
),
and_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date >= recently_expired_threshold,
),
),
Subscription.autopay_enabled == True,
Subscription.is_trial == False,
)
)
)
return list(result.scalars().all())
async def _process_single_subscription(
db: AsyncSession,
subscription: Subscription,
user: User,
bot: Bot | None,
payment_service,
subscription_service,
) -> str:
"""
Обрабатывает одну подписку: проверяет баланс, находит карту, создаёт автоплатёж.
Returns:
'created' — автоплатёж создан
'no_card' — нет сохранённой карты
'all_cards_failed' — все карты не сработали
'skipped' — баланс достаточен или другая причина пропуска
"""
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
# Рассчитываем стоимость продления
tariff = getattr(subscription, 'tariff', None)
if tariff:
autopay_period = tariff.get_shortest_period() or 30
else:
autopay_period = 30
try:
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine
# TOCTOU: lock user row before pricing to prevent concurrent promo/balance races
user = await lock_user_for_pricing(db, user.id)
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
autopay_period,
user=user,
)
renewal_cost = pricing.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости для рекуррентного платежа',
subscription_id=subscription.id,
user_id=user.id,
error=e,
)
return 'skipped'
if renewal_cost <= 0:
return 'skipped'
# Проверяем, хватает ли баланса
shortage = renewal_cost - user.balance_kopeks
if shortage <= 0:
# Баланса достаточно, обычный autopay справится
return 'skipped'
# Используем autopay_days_before конкретной подписки, если задан
days_before = getattr(subscription, 'autopay_days_before', None) or settings.DEFAULT_AUTOPAY_DAYS_BEFORE
days_until_expiry = (subscription.end_date - datetime.now(UTC)).total_seconds() / 86400
if days_until_expiry > days_before and subscription.status != SubscriptionStatus.EXPIRED.value:
return 'skipped'
# Нужно пополнить баланс — ищем сохранённую карту
saved_methods = await get_active_payment_methods_by_user(db, user.id)
if not saved_methods:
return 'no_card'
# Сумма пополнения = нехватка (минимум YOOKASSA_MIN_AMOUNT_KOPEKS)
min_amount = settings.YOOKASSA_MIN_AMOUNT_KOPEKS
topup_amount_kopeks = max(shortage, min_amount)
topup_amount_rubles = topup_amount_kopeks / 100
# Создаём автоплатёж
yookassa_service = payment_service.yookassa_service
if not yookassa_service or not yookassa_service.configured:
logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей')
return 'skipped'
description = settings.get_balance_payment_description(
topup_amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
)
metadata = {
'user_id': str(user.id),
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
'purpose': 'recurrent_topup',
'subscription_id': str(subscription.id),
'source': 'recurrent_payment_service',
}
# Перебираем все сохранённые карты пока не найдём рабочую
today = datetime.now(UTC).strftime('%Y-%m-%d')
for saved_method in saved_methods:
# Детерминированный ключ: при рестарте/повторе YooKassa вернёт тот же платёж
idem_key = f'recurrent_{subscription.id}_{saved_method.id}_{today}'
result = await yookassa_service.create_autopayment(
amount=topup_amount_rubles,
currency='RUB',
description=description,
payment_method_id=saved_method.yookassa_payment_method_id,
metadata=metadata,
idempotence_key=idem_key,
)
if not result:
card_display = f'*{saved_method.card_last4}' if saved_method.card_last4 else ''
logger.warning(
'Не удалось списать с карты, пробуем следующую',
user_id=user.id,
subscription_id=subscription.id,
payment_method_id=saved_method.yookassa_payment_method_id,
card_display=card_display,
)
continue
# Успешно — сохраняем локальную запись с привязкой к YooKassa ID
try:
from app.database.crud.yookassa import create_yookassa_payment
yookassa_created_at = None
if result.get('created_at'):
try:
yookassa_created_at = datetime.fromisoformat(result['created_at'].replace('Z', '+00:00'))
except Exception:
pass
result_payment = await create_yookassa_payment(
db=db,
user_id=user.id,
yookassa_payment_id=result['id'],
amount_kopeks=topup_amount_kopeks,
currency='RUB',
description=description,
status=result.get('status', 'pending'),
metadata_json=metadata,
yookassa_created_at=yookassa_created_at,
test_mode=result.get('test_mode', False),
)
if result_payment:
logger.info(
'Рекуррентный автоплатёж создан',
user_id=user.id,
subscription_id=subscription.id,
amount_kopeks=topup_amount_kopeks,
yookassa_payment_id=result['id'],
)
except Exception as e:
logger.warning('Ошибка создания локальной записи рекуррентного платежа', error=e)
# Уведомляем пользователя
if bot and user.telegram_id:
try:
from app.localization.texts import get_texts
texts = get_texts(user.language)
payment_status = result.get('status', '')
if result.get('paid'):
keyboard = _build_extend_keyboard(texts, subscription.id)
msg = texts.t(
'RECURRENT_TOPUP_SUCCESS',
'✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.',
).format(amount=settings.format_price(topup_amount_kopeks))
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
msg += f'\n📦 Тариф: «{subscription.tariff.name}»'
await bot.send_message(
chat_id=user.telegram_id,
text=msg,
parse_mode='HTML',
reply_markup=keyboard,
)
elif payment_status == 'pending':
logger.info(
'Рекуррентный платёж в обработке',
user_id=user.id,
yookassa_payment_id=result.get('id'),
)
except Exception as notify_error:
logger.warning('Ошибка уведомления об автоплатеже', notify_error=notify_error)
return 'created'
# Все карты не сработали — уведомляем пользователя
if bot and user.telegram_id:
try:
from app.localization.texts import get_texts
texts = get_texts(user.language)
keyboard = _build_extend_keyboard(texts, subscription.id)
msg = texts.t(
'RECURRENT_TOPUP_FAILED',
'❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.',
).format(amount=settings.format_price(topup_amount_kopeks))
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
msg += f'\n📦 Тариф: «{subscription.tariff.name}»'
await bot.send_message(
chat_id=user.telegram_id,
text=msg,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as notify_error:
logger.warning('Ошибка уведомления о неудачном автоплатеже', notify_error=notify_error)
return 'all_cards_failed'