469 lines
19 KiB
Python
469 lines
19 KiB
Python
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 get_db
|
|
|
|
async for db in get_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)
|
|
break
|
|
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 платежей")
|