Files
remnawave-bedolaga-telegram…/app/services/payment/common.py
gy9vin 5a64dbf209 feat(payments): добавить режим яркого промпта активации подписки
- Реализован режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP для яркого уведомления пользователей
  - При пополнении баланса отправляется внимание-привлекающее сообщение с восклицательными знаками
  - Динамические кнопки в зависимости от статуса подписки:
    * Активная платная подписка: "🔄 Продлить" + "📱 Изменить устройства"
    * Нет подписки/истекла/триал: "🔥 Активировать подписку"
  - Убраны дублирующие уведомления из yookassa.py (строка 851)
  - Убраны дублирующие уведомления из subscription_auto_purchase_service.py (строки 755, 918)
  - Режим включается через SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true в .env

  Файлы:
  - app/services/payment/common.py: добавлена логика яркого промпта
  - app/services/payment/yookassa.py: отключено старое уведомление для корзины
  - app/services/subscription_auto_purchase_service.py: отключены 2 блока старых уведомлений
2026-01-16 15:29:44 +03:00

311 lines
14 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.

"""Общие инструменты платёжного сервиса.
В этом модуле собраны методы, которые нужны всем платёжным каналам:
построение клавиатур, базовые уведомления и стандартная обработка
успешных платежей.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.exc import MissingGreenlet
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import get_db
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.payment_logger import payment_logger as logger
class PaymentCommonMixin:
"""Mixin с базовой логикой, которую используют остальные платёжные блоки."""
async def build_topup_success_keyboard(self, user: Any) -> InlineKeyboardMarkup:
"""Формирует клавиатуру по завершении платежа, подстраиваясь под пользователя."""
# Загружаем нужные тексты с учётом выбранного языка пользователя.
texts = get_texts(user.language if user else "ru")
# Определяем статус подписки, чтобы показать подходящую кнопку.
has_active_subscription = False
subscription = None
if user:
try:
subscription = user.subscription
has_active_subscription = bool(
subscription
and not getattr(subscription, "is_trial", False)
and getattr(subscription, "is_active", False)
)
except MissingGreenlet as error:
logger.warning(
"Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s",
getattr(user, "id", None),
error,
)
except Exception as error: # pragma: no cover - защитный код
logger.error(
"Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s",
getattr(user, "id", None),
error,
)
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
first_button = build_miniapp_or_callback_button(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard_rows: list[list[InlineKeyboardButton]] = [
[first_button],
]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
if user:
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
except Exception as cart_error:
logger.warning(
"Не удалось проверить наличие сохраненной корзины у пользователя %s: %s",
user.id,
cart_error,
)
has_saved_cart = False
if has_saved_cart:
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
)
])
else:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists, subscription=subscription):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
# Стандартные кнопки быстрого доступа к балансу и главному меню.
keyboard_rows.append([
build_miniapp_or_callback_button(
text="💰 Мой баланс",
callback_data="menu_balance",
)
])
keyboard_rows.append([
InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
async def _send_payment_success_notification(
self,
telegram_id: int,
amount_kopeks: int,
user: Any | None = None,
*,
db: AsyncSession | None = None,
payment_method_title: str | None = None,
) -> None:
"""Отправляет пользователю уведомление об успешном платеже."""
if not getattr(self, "bot", None):
# Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
return
user_snapshot = await self._ensure_user_snapshot(
telegram_id,
user,
db=db,
)
try:
payment_method = payment_method_title or "Банковская карта (YooKassa)"
# Проверяем, нужно ли показывать яркое предупреждение об активации
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
# Определяем статус подписки для выбора правильной кнопки
has_active_subscription = False
if user_snapshot:
try:
subscription = user_snapshot.subscription
has_active_subscription = bool(
subscription
and not getattr(subscription, "is_trial", False)
and getattr(subscription, "is_active", False)
)
except Exception:
pass
# Яркое сообщение с восклицательными знаками
message = (
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\n\n"
"💎 Средства зачислены на ваш баланс!\n\n"
"‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n"
"⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n"
"👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇"
)
# Формируем клавиатуру с кнопками действий
keyboard_rows: list[list[InlineKeyboardButton]] = []
# Кнопка активации или продления в зависимости от статуса
if has_active_subscription:
# Активная платная подписка - показываем продление и изменение устройств
keyboard_rows.append([
build_miniapp_or_callback_button(
text="🔄 ПРОДЛИТЬ ПОДПИСКУ",
callback_data="subscription_extend",
)
])
keyboard_rows.append([
build_miniapp_or_callback_button(
text="📱 Изменить количество устройств",
callback_data="subscription_change_devices",
)
])
else:
# Нет подписки или истекла - показываем только активацию
keyboard_rows.append([
build_miniapp_or_callback_button(
text="🔥 АКТИВИРОВАТЬ ПОДПИСКУ",
callback_data="menu_buy",
)
])
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
else:
# Стандартное сообщение с полной клавиатурой
keyboard = await self.build_topup_success_keyboard(user_snapshot)
message = (
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\n\n"
"Средства зачислены на ваш баланс!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
)
await self.bot.send_message(
chat_id=telegram_id,
text=message,
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю %s: %s",
telegram_id,
error,
)
async def _ensure_user_snapshot(
self,
telegram_id: int,
user: Any | None,
*,
db: AsyncSession | None = None,
) -> Any | None:
"""Гарантирует, что данные пользователя пригодны для построения клавиатуры."""
def _build_snapshot(source: Any | None) -> SimpleNamespace | None:
if source is None:
return None
subscription = getattr(source, "subscription", None)
subscription_snapshot = None
if subscription is not None:
subscription_snapshot = SimpleNamespace(
is_trial=getattr(subscription, "is_trial", False),
is_active=getattr(subscription, "is_active", False),
actual_status=getattr(subscription, "actual_status", None),
)
return SimpleNamespace(
id=getattr(source, "id", None),
telegram_id=getattr(source, "telegram_id", None),
language=getattr(source, "language", "ru"),
subscription=subscription_snapshot,
)
try:
snapshot = _build_snapshot(user)
except MissingGreenlet:
snapshot = None
if snapshot is not None:
return snapshot
fetch_session = db
if fetch_session is not None:
try:
fetched_user = await get_user_by_telegram_id(fetch_session, telegram_id)
return _build_snapshot(fetched_user)
except Exception as fetch_error:
logger.warning(
"Не удалось обновить пользователя %s из переданной сессии: %s",
telegram_id,
fetch_error,
)
try:
async for db_session in get_db():
fetched_user = await get_user_by_telegram_id(db_session, telegram_id)
return _build_snapshot(fetched_user)
except Exception as fetch_error:
logger.warning(
"Не удалось получить пользователя %s для уведомления: %s",
telegram_id,
fetch_error,
)
return None
async def process_successful_payment(
self,
payment_id: str,
amount_kopeks: int,
user_id: int,
payment_method: str,
) -> bool:
"""Общая точка учёта успешных платежей (используется провайдерами при необходимости)."""
try:
logger.info(
"Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s",
payment_id,
amount_kopeks / 100,
user_id,
payment_method,
)
return True
except Exception as error:
logger.error("Ошибка обработки платежа %s: %s", payment_id, error)
return False