Merge pull request #1398 from Gy9vin/main

Простая покупка подписки!
This commit is contained in:
Egor
2025-10-19 01:06:51 +03:00
committed by GitHub
18 changed files with 1695 additions and 161 deletions

View File

@@ -113,12 +113,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
# Лимит трафика в ГБ (0 — безлимит)
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
# Цены за периоды
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_30_DAYS=1000
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
@@ -130,10 +142,10 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
PRICE_PER_DEVICE=10000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true

View File

@@ -19,6 +19,7 @@ from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
)
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
users as admin_users,
@@ -162,7 +163,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
try:
await maintenance_service.start_monitoring()

View File

@@ -182,6 +182,13 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"

View File

@@ -30,6 +30,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
subscription = result.scalar_one_or_none()
if subscription:
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
@@ -149,7 +150,7 @@ async def create_paid_subscription(
await db.commit()
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
squad_uuids = list(connected_squads or [])
if update_server_counters and squad_uuids:
@@ -223,6 +224,9 @@ async def extend_subscription(
if subscription.user:
subscription.user.has_had_paid_subscription = True
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -234,6 +238,12 @@ async def extend_subscription(
subscription.id,
previous_status,
)
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
logger.warning(
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
subscription.id,
days
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
@@ -1117,6 +1127,8 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -1127,6 +1139,8 @@ async def check_and_update_subscription_status(
await db.refresh(subscription)
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
elif subscription.status == SubscriptionStatus.PENDING.value:
logger.info(f" Проверка PENDING подписки {subscription.id}, статус остается без изменений")
return subscription
@@ -1183,3 +1197,132 @@ async def create_subscription(
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return subscription
async def create_pending_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = False
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=False,
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
async def activate_pending_subscription(
db: AsyncSession,
user_id: int,
period_days: int = None
) -> Optional[Subscription]:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
from sqlalchemy import and_
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
# Находим pending подписку пользователя
result = await db.execute(
select(Subscription)
.where(
and_(
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value
)
)
)
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
return None
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
if period_days is not None:
if pending_subscription.end_date <= current_time:
# Если текущая дата окончания уже прошла, устанавливаем новую
pending_subscription.end_date = current_time + timedelta(days=period_days)
else:
# Если дата окончания в будущем, продляем её
pending_subscription.end_date = pending_subscription.end_date + timedelta(days=period_days)
# Обновляем дату начала, если она не установлена или в прошлом
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
pending_subscription.start_date = current_time
await db.commit()
await db.refresh(pending_subscription)
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
return pending_subscription

View File

@@ -54,6 +54,7 @@ class SubscriptionStatus(Enum):
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
PENDING = "pending"
class TransactionType(Enum):

View File

@@ -2,6 +2,7 @@ import logging
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -1644,6 +1645,107 @@ async def start_balance_edit(
await callback.answer()
@admin_required
@error_handler
async def start_send_user_message(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
user_id = int(callback.data.split('_')[-1])
target_user = await get_user_by_id(db, user_id)
if not target_user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
await state.update_data(direct_message_user_id=user_id)
texts = get_texts(db_user.language)
prompt = (
texts.t("ADMIN_USER_SEND_MESSAGE_PROMPT",
"✉️ <b>Отправка сообщения пользователю</b>\n\n"
"Введите текст, который бот отправит пользователю."
"\n\nВы можете отменить действие командой /cancel или кнопкой ниже." )
)
await callback.message.edit_text(
prompt,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")]
]
),
parse_mode="HTML",
)
await state.set_state(AdminStates.sending_user_message)
await callback.answer()
@admin_required
@error_handler
async def process_send_user_message(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
user_id = data.get("direct_message_user_id")
if not user_id:
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь для отправки сообщения не найден"))
await state.clear()
return
target_user = await get_user_by_id(db, int(user_id))
if not target_user:
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь не найден или был удалён"))
await state.clear()
return
text = (message.text or "").strip()
if not text:
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_EMPTY", "❌ Пожалуйста, введите непустое сообщение"))
return
confirmation_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]]
)
try:
await message.bot.send_message(target_user.telegram_id, text)
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_SUCCESS", "✅ Сообщение отправлено пользователю"),
reply_markup=confirmation_keyboard,
)
except TelegramForbiddenError:
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_FORBIDDEN", "⚠️ Пользователь заблокировал бота или не может получить сообщения."),
reply_markup=confirmation_keyboard,
)
except TelegramBadRequest as err:
logger.error("Ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_BAD_REQUEST", "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз."),
reply_markup=confirmation_keyboard,
)
return
except Exception as err:
logger.error("Неожиданная ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_ERROR", "Не удалось отправить сообщение. Попробуйте позже."),
reply_markup=confirmation_keyboard,
)
await state.clear()
return
await state.clear()
@admin_required
@error_handler
async def process_balance_edit(
@@ -4014,11 +4116,21 @@ def register_handlers(dp: Dispatcher):
start_balance_edit,
F.data.startswith("admin_user_balance_")
)
dp.message.register(
process_balance_edit,
AdminStates.editing_user_balance
)
dp.callback_query.register(
start_send_user_message,
F.data.startswith("admin_user_send_message_")
)
dp.message.register(
process_send_user_message,
AdminStates.sending_user_message
)
dp.callback_query.register(
show_inactive_users,

View File

@@ -153,7 +153,7 @@ async def show_main_menu(
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
subscription_is_active = False
if db_user.subscription:
@@ -892,7 +892,7 @@ async def handle_back_to_menu(
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
subscription_is_active = False
if db_user.subscription:
@@ -946,64 +946,74 @@ async def handle_back_to_menu(
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
subscription = getattr(user, "subscription", None)
if not subscription:
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
actual_status = (subscription.actual_status or "").lower()
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
days_left = 0
if subscription.end_date > current_time:
days_left = (subscription.end_date - current_time).days
if actual_status == "pending":
return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки")
if actual_status == "disabled":
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
if actual_status == "expired":
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
).format(end_date=end_date_text)
if actual_status == "trial":
if days_left > 1:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
end_date=end_date_text,
days=days_left,
)
elif days_left == 1:
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if actual_status == "active":
if days_left > 7:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
end_date=end_date_text,
days=days_left,
)
elif days_left > 1:
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
if days_left == 1:
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно")
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:

View File

@@ -0,0 +1,836 @@
"""Обработчики для простой покупки подписки."""
import logging
from typing import Optional, Dict, Any
from aiogram import types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard, get_happ_download_button_row
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.services.subscription_purchase_service import SubscriptionPurchaseService
from app.utils.decorators import error_handler
from app.states import SubscriptionStates
from app.utils.subscription_utils import get_display_subscription_link
logger = logging.getLogger(__name__)
@error_handler
async def start_simple_subscription_purchase(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Начинает процесс простой покупки подписки."""
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя активная подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and current_subscription.is_active:
await callback.answer("У вас уже есть активная подписка", show_alert=True)
return
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
period_days = subscription_params["period_days"]
recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks)
direct_purchase_min_balance = recorded_price
extra_components = []
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
if traffic_limit and traffic_limit > 0:
traffic_price = settings.get_traffic_price(traffic_limit)
direct_purchase_min_balance += traffic_price
extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}")
device_limit = subscription_params.get("device_limit", 1)
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
devices_price = additional_devices * settings.PRICE_PER_DEVICE
direct_purchase_min_balance += devices_price
extra_components.append(f"devices+{additional_devices}->{devices_price}")
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s",
db_user.id,
period_days,
price_kopeks,
recorded_price,
",".join(extra_components) if extra_components else "none",
direct_purchase_min_balance,
getattr(settings, "PRICE_30_DAYS", None),
)
can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s",
db_user.id,
user_balance_kopeks,
direct_purchase_min_balance,
can_pay_from_balance,
)
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
if can_pay_from_balance
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
)
)
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance",
)
])
keyboard_rows.extend(methods_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
def _calculate_simple_subscription_price(params: dict) -> int:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
attr_name = f"PRICE_{period_days}_DAYS"
attr_value = getattr(settings, attr_name, None)
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s",
period_days,
attr_name,
attr_value,
settings.BASE_SUBSCRIPTION_PRICE,
)
# Получаем цену для стандартного периода
if attr_value is not None:
return attr_value
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="simple_subscription_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="simple_subscription_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 MulenPay",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@error_handler
async def handle_simple_subscription_pay_with_balance(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает оплату простой подписки с баланса."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
total_required = recorded_price
extras = []
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
if traffic_limit and traffic_limit > 0:
traffic_price = settings.get_traffic_price(traffic_limit)
total_required += traffic_price
extras.append(f"traffic={traffic_limit}GB->{traffic_price}")
device_limit = subscription_params.get("device_limit", 1)
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
devices_price = additional_devices * settings.PRICE_PER_DEVICE
total_required += devices_price
extras.append(f"devices+{additional_devices}->{devices_price}")
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s",
db_user.id,
subscription_params["period_days"],
price_kopeks,
",".join(extras) if extras else "none",
total_required,
getattr(db_user, "balance_kopeks", 0),
)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
if user_balance_kopeks < total_required:
await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True)
return
try:
# Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
success = await subtract_user_balance(
db,
db_user,
price_kopeks,
f"Оплата подписки на {subscription_params['period_days']} дней",
consume_promo_offer=False,
)
if not success:
await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True)
return
# Проверяем, есть ли у пользователя уже подписка
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Если подписка уже существует, продлеваем её
subscription = await extend_subscription(
db=db,
subscription=existing_subscription,
days=subscription_params["period_days"]
)
# Обновляем параметры подписки
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
subscription.device_limit = subscription_params["device_limit"]
if subscription_params["squad_uuid"]:
subscription.connected_squads = [subscription_params["squad_uuid"]]
await db.commit()
await db.refresh(subscription)
else:
# Если подписки нет, создаём новую
from app.database.crud.subscription import create_paid_subscription
subscription = await create_paid_subscription(
db=db,
user_id=db_user.id,
duration_days=subscription_params["period_days"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
device_limit=subscription_params["device_limit"],
connected_squads=[subscription_params["squad_uuid"]] if subscription_params["squad_uuid"] else [],
update_server_counters=True,
)
if not subscription:
# Возвращаем средства на баланс в случае ошибки
from app.services.payment_service import add_user_balance
await add_user_balance(
db,
db_user.id,
price_kopeks,
f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней",
)
await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True)
return
# Обновляем баланс пользователя
await db.refresh(db_user)
# Обновляем или создаём ссылку подписки в RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error:
logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True)
# Отправляем уведомление об успешной покупке
success_message = (
f"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться")
def _fallback_connect_button() -> types.InlineKeyboardButton:
return types.InlineKeyboardButton(
text=connect_button_text,
callback_data="subscription_connect",
)
if connect_mode == "miniapp_subscription":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=subscription_link),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "miniapp_custom":
custom_url = settings.MINIAPP_CUSTOM_URL
if custom_url:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=custom_url),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "link":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
url=subscription_link,
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "happ_cryptolink":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
callback_data="open_subscription_link",
)
]
else:
connect_row = [_fallback_connect_button()]
else:
connect_row = [_fallback_connect_button()]
keyboard_rows = [connect_row]
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard_rows.append(happ_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
success_message,
reply_markup=keyboard,
parse_mode="HTML"
)
# Отправляем уведомление админам
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None, # transaction
subscription_params["period_days"],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админам о покупке: {e}")
await state.clear()
await callback.answer()
logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}")
except Exception as error:
logger.error(
"Ошибка оплаты простой подписки с баланса для пользователя %s: %s",
db_user.id,
error,
exc_info=True,
)
await callback.answer(
"❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.",
show_alert=True,
)
await state.clear()
@error_handler
async def handle_simple_subscription_pay_with_balance_disabled(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Показывает уведомление, если баланса недостаточно для прямой оплаты."""
await callback.answer(
"❌ Недостаточно средств на балансе. Пополните баланс или выберите другой способ оплаты.",
show_alert=True,
)
@error_handler
async def handle_simple_subscription_other_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает выбор других способов оплаты."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
total_required = recorded_price
if subscription_params.get("traffic_limit_gb", 0) > 0:
total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"])
if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT
total_required += additional_devices * settings.PRICE_PER_DEVICE
can_pay_from_balance = user_balance_kopeks >= total_required
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s",
db_user.id,
user_balance_kopeks,
price_kopeks,
total_required,
can_pay_from_balance,
)
# Отображаем доступные методы оплаты
message_text = (
f"💳 <b>Оплата подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
if can_pay_from_balance
else "Выберите подходящий способ оплаты:"
)
)
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance"
)
])
keyboard_rows.extend(base_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_simple_subscription_payment_method(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает выбор метода оплаты для простой подписки."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
payment_method = callback.data.replace("simple_subscription_", "")
try:
payment_service = PaymentService(callback.bot)
if payment_method == "stars":
# Оплата через Telegram Stars
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=f"Подписка на {subscription_params['period_days']} дней",
description=(
f"Простая покупка подписки\n"
f"Период: {subscription_params['period_days']} дней\n"
f"Устройства: {subscription_params['device_limit']}\n"
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
),
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
provider_token="", # Пустой токен для Telegram Stars
currency="XTR", # Telegram Stars
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
)
await state.clear()
await callback.answer()
elif payment_method in ["yookassa", "yookassa_sbp"]:
# Оплата через YooKassa
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата через YooKassa временно недоступна", show_alert=True)
return
if payment_method == "yookassa_sbp" and not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
# Создаем заказ на подписку
purchase_service = SubscriptionPurchaseService()
order = await purchase_service.create_subscription_order(
db=db,
user_id=db_user.id,
period_days=subscription_params["period_days"],
device_limit=subscription_params["device_limit"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
squad_uuid=subscription_params["squad_uuid"],
payment_method="yookassa_sbp" if payment_method == "yookassa_sbp" else "yookassa",
total_price_kopeks=price_kopeks
)
if not order:
await callback.answer("❌ Ошибка создания заказа", show_alert=True)
return
# Создаем платеж через YooKassa
if payment_method == "yookassa_sbp":
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=db_user.id,
amount_kopeks=price_kopeks,
description=f"Оплата подписки на {subscription_params['period_days']} дней",
receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None,
receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"order_id": str(order.id),
"subscription_period": str(subscription_params["period_days"]),
"payment_purpose": "simple_subscription_purchase"
}
)
else:
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=price_kopeks,
description=f"Оплата подписки на {subscription_params['period_days']} дней",
receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None,
receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"order_id": str(order.id),
"subscription_period": str(subscription_params["period_days"]),
"payment_purpose": "simple_subscription_purchase"
}
)
if not payment_result:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
# Отправляем QR-код и/или ссылку для оплаты
confirmation_url = payment_result.get("confirmation_url")
qr_confirmation_data = payment_result.get("qr_confirmation_data")
if not confirmation_url and not qr_confirmation_data:
await callback.answer("❌ Ошибка получения данных для оплаты", show_alert=True)
return
# Подготовим QR-код для вставки в основное сообщение
qr_photo = None
if qr_confirmation_data or confirmation_url:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Используем qr_confirmation_data если доступно, иначе confirmation_url
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода: {e}")
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
# Добавляем общие кнопки
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
message_text = (
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
message_text += (
f"📱 <b>Инструкция по оплате:</b>\n"
f"1. Откройте приложение вашего банка\n"
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n"
)
message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
)
# Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo:
# Используем метод отправки фото с описанием
await callback.message.edit_media(
media=types.InputMediaPhoto(
media=qr_photo,
caption=message_text,
parse_mode="HTML"
),
reply_markup=keyboard
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
await callback.answer()
elif payment_method == "cryptobot":
# Оплата через CryptoBot
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата через CryptoBot временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через CryptoBot
await callback.answer("❌ Оплата через CryptoBot пока не реализована", show_alert=True)
elif payment_method == "mulenpay":
# Оплата через MulenPay
if not settings.is_mulenpay_enabled():
await callback.answer("❌ Оплата через MulenPay временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через MulenPay
await callback.answer("❌ Оплата через MulenPay пока не реализована", show_alert=True)
elif payment_method == "pal24":
# Оплата через PayPalych
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через PayPalych
await callback.answer("❌ Оплата через PayPalych пока не реализована", show_alert=True)
elif payment_method == "wata":
# Оплата через WATA
if not settings.is_wata_enabled():
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через WATA
await callback.answer("❌ Оплата через WATA пока не реализована", show_alert=True)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки метода оплаты простой подписки: {e}")
await callback.answer("❌ Ошибка обработки запроса. Попробуйте позже или обратитесь в поддержку.", show_alert=True)
await state.clear()
def register_simple_subscription_handlers(dp):
"""Регистрирует обработчики простой покупки подписки."""
dp.callback_query.register(
start_simple_subscription_purchase,
F.data == "simple_subscription_purchase"
)
dp.callback_query.register(
handle_simple_subscription_pay_with_balance,
F.data == "simple_subscription_pay_with_balance"
)
dp.callback_query.register(
handle_simple_subscription_pay_with_balance_disabled,
F.data == "simple_subscription_pay_with_balance_disabled"
)
dp.callback_query.register(
handle_simple_subscription_other_payment_methods,
F.data == "simple_subscription_other_payment_methods"
)
dp.callback_query.register(
handle_simple_subscription_payment_method,
F.data.startswith("simple_subscription_")
)

View File

@@ -527,9 +527,22 @@ async def start_subscription_purchase(
):
texts = get_texts(db_user.language)
# Если включена простая покупка, показываем дополнительную кнопку
keyboard = get_subscription_period_keyboard(db_user.language)
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
# Добавляем кнопку простой подписки в начало клавиатуры
simple_subscription_button = [types.InlineKeyboardButton(
text="⚡ Простая покупка",
callback_data="simple_subscription_purchase"
)]
# Вставляем кнопку в начало списка кнопок
keyboard.inline_keyboard.insert(0, simple_subscription_button)
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=keyboard,
parse_mode="HTML",
)
@@ -1999,7 +2012,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade"])
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
)
dp.callback_query.register(
@@ -2264,3 +2277,162 @@ def register_handlers(dp: Dispatcher):
show_device_connection_help,
F.data == "device_connection_help"
)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
F.data == "simple_subscription_purchase"
)
async def handle_simple_subscription_purchase(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя активная подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and current_subscription.is_active:
await callback.answer("У вас уже есть активная подписка", show_alert=True)
return
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")]
])
else:
# Если баланс недостаточный, предлагаем внешние способы оплаты
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Выберите способ оплаты:"
)
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
def _calculate_simple_subscription_price(params: dict) -> int:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
# Получаем цену для стандартного периода
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
return getattr(settings, f'PRICE_{period_days}_DAYS')
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="simple_subscription_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="simple_subscription_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 MulenPay",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)

View File

@@ -762,6 +762,13 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
]
]
keyboard.append([
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"),
callback_data=f"admin_user_send_message_{user_id}"
)
])
if user_status == "active":
keyboard.append([
InlineKeyboardButton(

View File

@@ -337,12 +337,22 @@ def get_main_menu_keyboard(
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
)
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
subscription_buttons.append(
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
)
if subscription_buttons:
if len(subscription_buttons) == 2:
keyboard.append(subscription_buttons)
else:
elif len(subscription_buttons) == 1:
keyboard.append([subscription_buttons[0]])
elif len(subscription_buttons) > 2:
# Если больше 2 кнопок, добавляем по отдельности
for button in subscription_buttons:
keyboard.append([button])
if show_resume_checkout or has_saved_cart:
keyboard.append([
@@ -870,6 +880,8 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])

View File

@@ -695,6 +695,14 @@
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
"ADMIN_USER_TRANSACTIONS": "📋 Transactions",
"ADMIN_USER_SEND_MESSAGE": "✉️ Send message",
"ADMIN_USER_SEND_MESSAGE_PROMPT": "✉️ <b>Send a message to the user</b>\n\nType the text that the bot should send.\n\nYou can cancel with /cancel or the button below.",
"ADMIN_USER_SEND_MESSAGE_SUCCESS": "✅ Message sent to the user",
"ADMIN_USER_SEND_MESSAGE_FORBIDDEN": "⚠️ The user blocked the bot or cannot receive messages.",
"ADMIN_USER_SEND_MESSAGE_BAD_REQUEST": "❌ Telegram rejected the message. Check the text and try again.",
"ADMIN_USER_SEND_MESSAGE_ERROR": "❌ Couldn't send the message. Please try again later.",
"ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND": "❌ User not found",
"ADMIN_USER_SEND_MESSAGE_EMPTY": "❌ Please enter a non-empty message",
"ADMIN_USER_UNBLOCK": "✅ Unblock",
"ADMIN_USER_USERNAME_NOT_SET": "not set",
"ADMIN_WELCOME_DISABLE": "🔴 Disable",

View File

@@ -695,6 +695,14 @@
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
"ADMIN_USER_TRANSACTIONS": "📋 Транзакции",
"ADMIN_USER_SEND_MESSAGE": "✉️ Отправить сообщение",
"ADMIN_USER_SEND_MESSAGE_PROMPT": "✉️ <b>Отправка сообщения пользователю</b>\n\nВведите текст, который бот отправит пользователю.\n\nМожно отменить командой /cancel или кнопкой ниже.",
"ADMIN_USER_SEND_MESSAGE_SUCCESS": "✅ Сообщение отправлено пользователю",
"ADMIN_USER_SEND_MESSAGE_FORBIDDEN": "⚠️ Пользователь заблокировал бота или не может получать сообщения.",
"ADMIN_USER_SEND_MESSAGE_BAD_REQUEST": "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз.",
"ADMIN_USER_SEND_MESSAGE_ERROR": "❌ Не удалось отправить сообщение. Попробуйте позже.",
"ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND": "❌ Пользователь не найден",
"ADMIN_USER_SEND_MESSAGE_EMPTY": "❌ Пожалуйста, введите непустое сообщение",
"ADMIN_USER_UNBLOCK": "✅ Разблокировать",
"ADMIN_USER_USERNAME_NOT_SET": "не указан",
"ADMIN_WELCOME_DISABLE": "🔴 Отключить",

View File

@@ -217,9 +217,10 @@ class AdminNotificationService:
db: AsyncSession,
user: User,
subscription: Subscription,
transaction: Transaction,
transaction: Optional[Transaction],
period_days: int,
was_trial_conversion: bool = False
was_trial_conversion: bool = False,
amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
@@ -235,11 +236,14 @@ class AdminNotificationService:
user_status = "🆕 Первая покупка"
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else ""
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -250,9 +254,9 @@ class AdminNotificationService:
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💵 Сумма: {settings.format_price(total_amount)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
🆔 ID транзакции: {transaction_id}
📱 <b>Параметры подписки:</b>
📅 Период: {period_days} дней

View File

@@ -217,12 +217,39 @@ class YooKassaPaymentMixin:
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_metadata: Dict[str, Any] = {}
try:
if hasattr(payment, "metadata_json") and payment.metadata_json:
import json
if isinstance(payment.metadata_json, str):
payment_metadata = json.loads(payment.metadata_json)
elif isinstance(payment.metadata_json, dict):
payment_metadata = payment.metadata_json
logger.info(f"Метаданные платежа: {payment_metadata}")
except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
payment_purpose = payment_metadata.get("payment_purpose", "")
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
if is_simple_subscription
else TransactionType.DEPOSIT
)
transaction_description = (
f"Оплата подписки через YooKassa: {payment_description}"
if is_simple_subscription
else f"Пополнение через YooKassa: {payment_description}"
)
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через YooKassa: {payment_description}",
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
@@ -236,143 +263,257 @@ class YooKassaPaymentMixin:
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
old_balance = getattr(user, "balance_kopeks", 0)
was_first_topup = not getattr(user, "has_made_first_topup", False)
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
if is_simple_subscription:
logger.info(
"YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.",
payment.yookassa_payment_id,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения YooKassa: %s",
error,
else:
old_balance = getattr(user, "balance_kopeks", 0)
was_first_topup = not getattr(user, "has_made_first_topup", False)
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
if was_first_topup and not getattr(user, "has_made_first_topup", False):
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Отправляем уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
from app.services.referral_service import process_referral_topup
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
logger.info("Уведомление админам о пополнении отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Отправляем уведомление пользователю
if getattr(self, "bot", None):
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title="Банковская карта (YooKassa)",
getattr(self, "bot", None),
)
logger.info("Уведомление пользователю о платеже отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s",
"Ошибка обработки реферального пополнения YooKassa: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
if was_first_topup and not getattr(user, "has_made_first_topup", False):
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Отправляем уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
logger.info("Уведомление админам о пополнении отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
exc_info=True, # Добавляем полный стек вызовов для отладки
)
# Отправляем уведомление пользователю
if getattr(self, "bot", None):
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title="Банковская карта (YooKassa)",
)
logger.info("Уведомление пользователю о платеже отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s",
error,
exc_info=True, # Добавляем полный стек вызовов для отладки
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
)
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
except Exception as e:
logger.error(
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
exc_info=True,
)
if is_simple_subscription:
logger.info(f"Обнаружен платеж простой покупки подписки для пользователя {user.id}")
try:
# Активируем подписку
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
# Получаем параметры подписки из метаданных
subscription_period = int(payment_metadata.get("subscription_period", 30))
order_id = payment_metadata.get("order_id")
logger.info(f"Активация подписки: период={subscription_period} дней, заказ={order_id}")
# Активируем pending подписку пользователя
from app.database.crud.subscription import activate_pending_subscription
subscription = await activate_pending_subscription(
db=db,
user_id=user.id,
period_days=subscription_period
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
except Exception as e:
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
if subscription:
logger.info(f"Подписка успешно активирована для пользователя {user.id}")
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
try:
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error:
logger.error(
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
user.id,
sync_error,
exc_info=True,
)
# Отправляем уведомление пользователю об активации подписки
if getattr(self, "bot", None):
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
success_message = (
f"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {subscription_period} дней\n"
f"📱 Устройства: 1\n"
f"📊 Трафик: Безлимит\n"
f"💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n"
f"🔗 Для подключения перейдите в раздел 'Моя подписка'"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=success_message,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_subscription_purchase_notification(
db,
user,
subscription,
transaction,
subscription_period,
was_trial_conversion=False,
)
except Exception as admin_error:
logger.error(
"Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s",
admin_error,
exc_info=True,
)
else:
logger.error(f"Ошибка активации подписки для пользователя {user.id}")
except Exception as e:
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
if is_simple_subscription:
logger.info(
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
else:
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
return True

View File

@@ -1175,5 +1175,38 @@ class MiniAppSubscriptionPurchaseService:
}
class SubscriptionPurchaseService:
"""Service for handling simple subscription purchases with predefined parameters."""
async def create_subscription_order(
self,
db: AsyncSession,
user_id: int,
period_days: int,
device_limit: int,
traffic_limit_gb: int,
squad_uuid: str,
payment_method: str,
total_price_kopeks: int
):
"""Creates a subscription order with predefined parameters."""
from app.database.crud.subscription import create_pending_subscription
from app.database.models import SubscriptionStatus
# Create a pending subscription
subscription = await create_pending_subscription(
db=db,
user_id=user_id,
duration_days=period_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else [],
payment_method=payment_method,
total_price_kopeks=total_price_kopeks
)
return subscription
purchase_service = MiniAppSubscriptionPurchaseService()

View File

@@ -66,10 +66,30 @@ class UserService:
f"Если у вас есть вопросы, обратитесь в поддержку."
)
keyboard_rows = []
if getattr(user, "subscription", None) and user.subscription.status in {
"active",
"expired",
"trial",
}:
keyboard_rows.append([
types.InlineKeyboardButton(
text=get_texts(user.language).t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
])
reply_markup = (
types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
if keyboard_rows
else None
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
parse_mode="HTML",
reply_markup=reply_markup,
)
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")

View File

@@ -18,6 +18,9 @@ class SubscriptionStates(StatesGroup):
extending_subscription = State()
confirming_traffic_reset = State()
cart_saved_for_topup = State()
# Состояния для простой подписки
waiting_for_simple_subscription_payment_method = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
@@ -33,6 +36,7 @@ class PromoCodeStates(StatesGroup):
class AdminStates(StatesGroup):
waiting_for_user_search = State()
sending_user_message = State()
editing_user_balance = State()
extending_subscription = State()
adding_traffic = State()