Files
2026-01-17 01:16:10 +03:00

468 lines
19 KiB
Python
Raw Permalink 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.

import logging
from decimal import Decimal, ROUND_HALF_UP
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def _handle_wheel_spin_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
texts,
):
"""Обработка Stars платежа для колеса удачи."""
from app.services.wheel_service import wheel_service
from app.database.crud.wheel import get_or_create_wheel_config, get_wheel_prizes
try:
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
await message.answer(
"❌ Колесо удачи временно недоступно. Звезды будут возвращены.",
)
return False
# Выполняем спин напрямую (оплата уже прошла через Stars)
prizes = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
await message.answer(
"❌ Призы не настроены. Обратитесь в поддержку.",
)
return False
# Рассчитываем стоимость в копейках для статистики
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
payment_value_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Рассчитываем вероятности и выбираем приз
prizes_with_probs = wheel_service.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
selected_prize = wheel_service._select_prize(prizes_with_probs)
# Применяем приз
generated_promocode = await wheel_service._apply_prize(db, user, selected_prize, config)
# Создаем запись спина
from app.database.crud.wheel import create_wheel_spin
from app.database.models import WheelSpinPaymentType
promocode_id = None
if generated_promocode:
result = await db.execute(
f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'"
)
row = result.fetchone()
if row:
promocode_id = row[0]
logger.info(
f"🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, "
f"prize={selected_prize.display_name}"
)
spin = await create_wheel_spin(
db=db,
user_id=user.id,
prize_id=selected_prize.id,
payment_type=WheelSpinPaymentType.TELEGRAM_STARS.value,
payment_amount=stars_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
prize_value_kopeks=selected_prize.prize_value_kopeks,
generated_promocode_id=promocode_id,
is_applied=True,
)
logger.info(f"🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}")
# Ensure all changes are committed (subscription days, traffic GB, etc.)
await db.commit()
# Отправляем результат
prize_message = wheel_service._get_prize_message(selected_prize, generated_promocode)
emoji = selected_prize.emoji or "🎁"
await message.answer(
f"🎰 <b>Колесо удачи!</b>\n\n"
f"{emoji} <b>{selected_prize.display_name}</b>\n\n"
f"{prize_message}\n\n"
f"⭐ Потрачено: {stars_amount} Stars",
parse_mode="HTML",
)
logger.info(
f"🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, "
f"stars={stars_amount}"
)
return True
except Exception as e:
logger.error(f"Ошибка обработки wheel spin payment: {e}", exc_info=True)
await message.answer(
"❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.",
)
return False
async def _handle_trial_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
texts,
):
"""Обработка Stars платежа для платного триала."""
from app.database.crud.subscription import activate_pending_trial_subscription
from app.services.subscription_service import SubscriptionService
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod
try:
# Парсим payload: trial_{subscription_id}
parts = payload.split("_")
if len(parts) < 2:
logger.error(f"Невалидный trial payload: {payload}")
await message.answer(
"❌ Ошибка: неверный формат платежа. Обратитесь в поддержку.",
)
return False
try:
subscription_id = int(parts[1])
except ValueError:
logger.error(f"Невалидный subscription_id в trial payload: {payload}")
await message.answer(
"❌ Ошибка: неверный ID подписки. Обратитесь в поддержку.",
)
return False
# Рассчитываем стоимость в копейках
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Создаём транзакцию
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=amount_kopeks,
description=f"Оплата пробной подписки через Telegram Stars ({stars_amount} ⭐)",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=f"trial_stars_{subscription_id}",
is_completed=True,
)
# Активируем pending триальную подписку
subscription = await activate_pending_trial_subscription(
db=db,
subscription_id=subscription_id,
user_id=user.id,
)
if not subscription:
logger.error(f"Не удалось активировать триальную подписку {subscription_id} для пользователя {user.id}")
# Возвращаем деньги на баланс
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
amount_kopeks,
"Возврат за неудачную активацию триала",
transaction_type=TransactionType.REFUND,
)
await message.answer(
"Не удалось активировать пробную подписку. Средства возвращены на баланс.",
)
return False
# Создаем пользователя в RemnaWave
subscription_service = SubscriptionService()
try:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as rw_error:
logger.error(f"Ошибка создания пользователя RemnaWave для триала: {rw_error}")
# Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен
await db.commit()
await db.refresh(user)
# Отправляем уведомление админам
try:
admin_notification_service = AdminNotificationService(message.bot)
await admin_notification_service.send_trial_activation_notification(
user=user,
subscription=subscription,
paid_amount=amount_kopeks,
payment_method="Telegram Stars",
)
except Exception as admin_error:
logger.warning(f"Ошибка отправки уведомления админам о триале: {admin_error}")
# Отправляем сообщение пользователю
await message.answer(
f"🎉 <b>Пробная подписка активирована!</b>\n\n"
f"⭐ Потрачено: {stars_amount} Stars\n"
f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n"
f"📱 Устройств: {subscription.device_limit}\n\n"
f"Используйте меню для подключения к VPN.",
parse_mode="HTML",
)
logger.info(
f"✅ Платный триал активирован через Stars: user={user.id}, "
f"subscription={subscription.id}, stars={stars_amount}"
)
return True
except Exception as e:
logger.error(f"Ошибка обработки trial payment: {e}", exc_info=True)
await message.answer(
"❌ Произошла ошибка при активации пробной подписки. Обратитесь в поддержку.",
)
return False
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
)
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_", "wheel_spin_", "trial_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_INVALID_PAYLOAD",
"Ошибка валидации платежа. Попробуйте еще раз.",
),
)
return
try:
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_USER_NOT_FOUND",
"Пользователь не найден. Обратитесь в поддержку.",
),
)
return
texts = get_texts(user.language or DEFAULT_LANGUAGE)
except Exception as db_error:
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
return
await query.answer(ok=True)
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
except Exception as e:
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
state: FSMContext,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
try:
payment = message.successful_payment
user_id = message.from_user.id
logger.info(
f"💳 Успешный Stars платеж от {user_id}: "
f"{payment.total_amount} XTR, "
f"payload: {payment.invoice_payload}, "
f"charge_id: {payment.telegram_payment_charge_id}"
)
user = await get_user_by_telegram_id(db, user_id)
texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE)
if not user:
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
await message.answer(
texts.t(
"STARS_PAYMENT_USER_NOT_FOUND",
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
)
)
return
# Обработка оплаты спина колеса удачи
if payment.invoice_payload and payment.invoice_payload.startswith("wheel_spin_"):
await _handle_wheel_spin_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
texts=texts,
)
return
# Обработка оплаты платного триала
if payment.invoice_payload and payment.invoice_payload.startswith("trial_"):
await _handle_trial_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
texts=texts,
)
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
invoice_message_id = state_data.get("stars_invoice_message_id")
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
for chat_id, message_id, label in [
(prompt_chat_id, prompt_message_id, "запрос суммы"),
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
]:
if message_id:
try:
await message.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning(
"Не удалось удалить сообщение %s после оплаты Stars: %s",
label,
delete_error,
)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
await state.update_data(
stars_prompt_message_id=None,
stars_prompt_chat_id=None,
stars_invoice_message_id=None,
stars_invoice_chat_id=None,
)
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
amount_text = settings.format_price(amount_kopeks).replace("", "")
keyboard = await payment_service.build_topup_success_keyboard(user)
transaction_id_short = payment.telegram_payment_charge_id[:8]
await message.answer(
texts.t(
"STARS_PAYMENT_SUCCESS",
"🎉 <b>Платеж успешно обработан!</b>\n\n"
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=amount_text,
transaction_id=transaction_id_short,
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s",
user.id,
payment.total_amount,
settings.format_price(amount_kopeks),
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
await message.answer(
texts.t(
"STARS_PAYMENT_ENROLLMENT_ERROR",
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную.",
)
)
except Exception as e:
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
await message.answer(
texts.t(
"STARS_PAYMENT_PROCESSING_ERROR",
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы.",
)
)
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")