diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index efd1a74..14f4dd1 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -17,7 +17,7 @@ from db.dal import payment_dal, user_dal from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService from bot.services.panel_api_service import PanelApiService -from bot.services.payment_service import YooKassaService +from bot.services.yookassa_service import YooKassaService from bot.middlewares.i18n import JsonI18n from config.settings import Settings diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 2bd6045..e10cbd7 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -7,11 +7,12 @@ from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from db.dal import payment_dal, user_dal +from db.dal import payment_dal from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_payment_method_keyboard, get_payment_url_keyboard, get_back_to_main_menu_markup) -from bot.services.payment_service import YooKassaService +from bot.services.yookassa_service import YooKassaService +from bot.services.stars_service import StarsService from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService @@ -129,7 +130,7 @@ async def select_subscription_period_callback_handler( @router.callback_query(F.data.startswith("pay_stars:")) async def pay_stars_callback_handler( callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - session: AsyncSession, bot: Bot): + session: AsyncSession, bot: Bot, stars_service: StarsService): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -152,41 +153,9 @@ async def pay_stars_callback_handler( user_id = callback.from_user.id payment_description = get_text("payment_description_subscription", months=months) - payment_record_data = { - "user_id": user_id, - "amount": float(stars_price), - "currency": "XTR", - "status": "pending_stars", - "description": payment_description, - "subscription_duration_months": months, - "provider": "telegram_stars", - } - - try: - db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) - await session.commit() - except Exception as e_db_payment: - await session.rollback() - logging.error(f"Failed to create stars payment record: {e_db_payment}", exc_info=True) - await callback.message.edit_text(get_text("error_creating_payment_record")) - await callback.answer(get_text("error_try_again"), show_alert=True) - return - - payload = f"{db_payment_record.payment_id}:{months}" - prices = [LabeledPrice(label=payment_description, amount=stars_price)] - - try: - await bot.send_invoice( - chat_id=user_id, - title=payment_description, - description=payment_description, - payload=payload, - provider_token="", - currency="XTR", - prices=prices, - ) - except Exception as e_inv: - logging.error(f"Failed to send Telegram Stars invoice: {e_inv}", exc_info=True) + payment_id = await stars_service.create_invoice( + session, user_id, months, stars_price, payment_description) + if payment_id is None: await callback.message.edit_text(get_text("error_payment_gateway")) await callback.answer(get_text("error_try_again"), show_alert=True) return @@ -425,8 +394,7 @@ async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery) @router.message(F.successful_payment) async def stars_successful_payment_handler( message: types.Message, settings: Settings, i18n_data: dict, - session: AsyncSession, bot: Bot, panel_service: PanelApiService, - subscription_service: SubscriptionService, referral_service: ReferralService): + session: AsyncSession, stars_service: StarsService): sp = message.successful_payment if not sp or sp.currency != "XTR": return @@ -440,67 +408,9 @@ async def stars_successful_payment_handler( logging.error(f"Invalid invoice payload for stars payment: {payload}") return - provider_payment_id = sp.provider_payment_charge_id stars_amount = sp.total_amount - try: - await payment_dal.update_provider_payment_and_status( - session, payment_db_id, provider_payment_id, "succeeded") - await session.commit() - except Exception as e_upd: - await session.rollback() - logging.error(f"Failed to update stars payment record {payment_db_id}: {e_upd}", exc_info=True) - return - - activation_details = await subscription_service.activate_subscription( - session, - message.from_user.id, - months, - float(stars_amount), - payment_db_id, - provider="telegram_stars", - ) - - if not activation_details or not activation_details.get("end_date"): - logging.error(f"Failed to activate subscription after stars payment for user {message.from_user.id}") - return - - referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( - session, message.from_user.id, months) - await session.commit() - - applied_referee_days = referral_bonus_info.get("referee_bonus_applied_days") if referral_bonus_info else None - final_end = (referral_bonus_info.get("referee_new_end_date") - if referral_bonus_info else None) - if not final_end: - final_end = activation_details["end_date"] - - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: JsonI18n = i18n_data.get("i18n_instance") - _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k - - if applied_referee_days: - inviter_name_display = _("friend_placeholder") - db_user = await user_dal.get_user_by_id(session, message.from_user.id) - if db_user and db_user.referred_by_id: - inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" - success_msg = _("payment_successful_with_referral_bonus", - months=months, - base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), - bonus_days=applied_referee_days, - final_end_date=final_end.strftime('%Y-%m-%d'), - inviter_name=inviter_name_display) - else: - success_msg = _("payment_successful", months=months, - end_date=final_end.strftime('%Y-%m-%d')) - - try: - await bot.send_message(message.from_user.id, success_msg) - except Exception as e_send: - logging.error(f"Failed to send stars payment success message: {e_send}") + await stars_service.process_successful_payment( + session, message, payment_db_id, months, stars_amount, i18n_data) @router.message(Command("connect")) diff --git a/bot/handlers/webhooks/tribute.py b/bot/handlers/webhooks/tribute.py index e61f075..cd4cee4 100644 --- a/bot/handlers/webhooks/tribute.py +++ b/bot/handlers/webhooks/tribute.py @@ -1,130 +1,9 @@ -import logging -import hmac -import hashlib -import json from aiohttp import web -from aiogram import Bot -from sqlalchemy.orm import sessionmaker +from bot.services.tribute_service import TributeService -from config.settings import Settings -from bot.middlewares.i18n import JsonI18n -from bot.services.subscription_service import SubscriptionService -from bot.services.panel_api_service import PanelApiService -from bot.services.referral_service import ReferralService -from db.dal import payment_dal, user_dal, subscription_dal async def tribute_webhook_route(request: web.Request): - bot: Bot = request.app['bot'] - settings: Settings = request.app['settings'] - i18n: JsonI18n = request.app['i18n'] - async_session_factory: sessionmaker = request.app['async_session_factory'] - panel_service: PanelApiService = request.app['panel_service'] - subscription_service: SubscriptionService = request.app['subscription_service'] - referral_service: ReferralService = request.app['referral_service'] - + tribute_service: TributeService = request.app['tribute_service'] raw_body = await request.read() signature_header = request.headers.get('trbt-signature') - if settings.TRIBUTE_API_KEY: - if not signature_header: - return web.Response(status=403, text="no_signature") - expected_sig = hmac.new( - settings.TRIBUTE_API_KEY.encode(), raw_body, hashlib.sha256 - ).hexdigest() - if not hmac.compare_digest(expected_sig, signature_header): - return web.Response(status=403, text="invalid_signature") - - try: - payload = json.loads(raw_body.decode()) - except Exception: - return web.Response(status=400, text="bad_request") - - event_name = payload.get('name') - data = payload.get('payload', {}) - user_id = data.get('telegram_user_id') - price_val = data.get('price') - - if not user_id or price_val is None: - return web.Response(status=200, text="ok_missing_fields") - - months_map = {int(v): m for m, v in settings.subscription_options.items()} - price_rub = price_val / 100 - months = months_map.get(int(price_rub)) - if not months: - logging.warning(f"Tribute webhook: price {price_val} not mapped to months") - return web.Response(status=200, text="ok_price_unmapped") - - async with async_session_factory() as session: - if event_name == 'new_subscription': - payment_record = await payment_dal.create_payment_record( - session, - { - 'user_id': user_id, - 'amount': float(price_rub), - 'currency': 'RUB', - 'status': 'succeeded', - 'description': 'Tribute subscription', - 'subscription_duration_months': months, - 'provider_payment_id': str(data.get('subscription_id')), - 'provider': 'tribute', - }, - ) - activation_details = await subscription_service.activate_subscription( - session, - user_id, - months, - float(price_rub), - payment_record.payment_id, - provider='tribute', - ) - referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, user_id, months - ) - await session.commit() - - db_user = await user_dal.get_user_by_id(session, user_id) - lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE - _ = lambda k, **kw: i18n.gettext(lang, k, **kw) - - applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None - final_end = (referral_bonus.get('referee_new_end_date') - if referral_bonus else None) - if not final_end: - final_end = activation_details.get('end_date') - - if final_end: - if applied_ref_days: - inviter_name_display = _('friend_placeholder') - if db_user and db_user.referred_by_id: - inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" - success_msg = _("payment_successful_with_referral_bonus", - months=months, - base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), - bonus_days=applied_ref_days, - final_end_date=final_end.strftime('%Y-%m-%d'), - inviter_name=inviter_name_display) - else: - success_msg = _("payment_successful", months=months, - end_date=final_end.strftime('%Y-%m-%d')) - - try: - await bot.send_message(user_id, success_msg) - except Exception as e: - logging.error(f"Failed to send Tribute payment success message to user {user_id}: {e}") - elif event_name == 'cancelled_subscription': - db_user = await user_dal.get_user_by_id(session, user_id) - lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE - _ = lambda k, **kw: i18n.gettext(lang, k, **kw) - try: - await bot.send_message(user_id, _("subscription_cancelled_notification")) - except Exception as e: - logging.warning(f"Failed to notify user {user_id} about cancellation: {e}") - await subscription_dal.set_skip_notifications_for_provider( - session, user_id, 'tribute', False) - await session.commit() - else: - await session.commit() - return web.Response(status=200, text="ok") + return await tribute_service.handle_webhook(raw_body, signature_header) diff --git a/bot/main_bot.py b/bot/main_bot.py index 9071663..ba63d3c 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -26,11 +26,13 @@ from bot.handlers.admin import admin_router_aggregate from bot.filters.admin_filter import AdminFilter from bot.services.notification_service import schedule_subscription_notifications -from bot.services.payment_service import YooKassaService +from bot.services.yookassa_service import YooKassaService from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService from bot.services.promo_code_service import PromoCodeService +from bot.services.stars_service import StarsService +from bot.services.tribute_service import TributeService from bot.handlers.user import payment as user_payment_webhook_module from bot.handlers.webhooks import tribute as tribute_webhook_module @@ -238,6 +240,12 @@ async def run_bot(settings_param: Settings): bot, i18n_instance) promo_code_service = PromoCodeService(settings_param, subscription_service, bot, i18n_instance) + stars_service = StarsService(bot, settings_param, i18n_instance, + subscription_service, referral_service) + tribute_service = TributeService(bot, settings_param, i18n_instance, + local_async_session_factory, + panel_service, subscription_service, + referral_service) dp["i18n_instance"] = i18n_instance dp["yookassa_service"] = yookassa_service @@ -245,6 +253,8 @@ async def run_bot(settings_param: Settings): dp["subscription_service"] = subscription_service dp["referral_service"] = referral_service dp["promo_code_service"] = promo_code_service + dp["stars_service"] = stars_service + dp["tribute_service"] = tribute_service dp["async_session_factory"] = local_async_session_factory dp.update.outer_middleware( @@ -298,6 +308,8 @@ async def run_bot(settings_param: Settings): app['subscription_service'] = subscription_service app['referral_service'] = referral_service app['panel_service'] = panel_service + app['stars_service'] = stars_service + app['tribute_service'] = tribute_service setup_application(app, dp, bot=bot) diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py new file mode 100644 index 0000000..bc5d536 --- /dev/null +++ b/bot/services/stars_service.py @@ -0,0 +1,135 @@ +import logging +from typing import Optional + +from aiogram import Bot, types +from aiogram.types import LabeledPrice +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import payment_dal, user_dal +from .subscription_service import SubscriptionService +from .referral_service import ReferralService +from bot.middlewares.i18n import JsonI18n + + +class StarsService: + def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, + subscription_service: SubscriptionService, + referral_service: ReferralService): + self.bot = bot + self.settings = settings + self.i18n = i18n + self.subscription_service = subscription_service + self.referral_service = referral_service + + async def create_invoice(self, session: AsyncSession, user_id: int, months: int, + stars_price: int, description: str) -> Optional[int]: + payment_record_data = { + "user_id": user_id, + "amount": float(stars_price), + "currency": "XTR", + "status": "pending_stars", + "description": description, + "subscription_duration_months": months, + "provider": "telegram_stars", + } + try: + db_payment_record = await payment_dal.create_payment_record( + session, payment_record_data) + await session.commit() + except Exception as e_db: + await session.rollback() + logging.error(f"Failed to create stars payment record: {e_db}", + exc_info=True) + return None + + payload = f"{db_payment_record.payment_id}:{months}" + prices = [LabeledPrice(label=description, amount=stars_price)] + try: + await self.bot.send_invoice( + chat_id=user_id, + title=description, + description=description, + payload=payload, + provider_token="", + currency="XTR", + prices=prices, + ) + return db_payment_record.payment_id + except Exception as e_inv: + logging.error(f"Failed to send Telegram Stars invoice: {e_inv}", + exc_info=True) + return None + + async def process_successful_payment(self, session: AsyncSession, + message: types.Message, + payment_db_id: int, + months: int, + stars_amount: int, + i18n_data: dict) -> None: + try: + await payment_dal.update_provider_payment_and_status( + session, payment_db_id, + message.successful_payment.provider_payment_charge_id, + "succeeded") + await session.commit() + except Exception as e_upd: + await session.rollback() + logging.error( + f"Failed to update stars payment record {payment_db_id}: {e_upd}", + exc_info=True) + return + + activation_details = await self.subscription_service.activate_subscription( + session, + message.from_user.id, + months, + float(stars_amount), + payment_db_id, + provider="telegram_stars", + ) + if not activation_details or not activation_details.get("end_date"): + logging.error( + f"Failed to activate subscription after stars payment for user {message.from_user.id}") + return + + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, message.from_user.id, months) + await session.commit() + + applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None + final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None + if not final_end: + final_end = activation_details["end_date"] + + current_lang = i18n_data.get("current_language", + self.settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k + + if applied_days: + inviter_name_display = _("friend_placeholder") + db_user = await user_dal.get_user_by_id(session, message.from_user.id) + if db_user and db_user.referred_by_id: + inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) + if inviter and inviter.first_name: + inviter_name_display = inviter.first_name + elif inviter and inviter.username: + inviter_name_display = f"@{inviter.username}" + success_msg = _( + "payment_successful_with_referral_bonus", + months=months, + base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), + bonus_days=applied_days, + final_end_date=final_end.strftime('%Y-%m-%d'), + inviter_name=inviter_name_display, + ) + else: + success_msg = _("payment_successful", months=months, + end_date=final_end.strftime('%Y-%m-%d')) + try: + await self.bot.send_message(message.from_user.id, success_msg) + except Exception as e_send: + logging.error( + f"Failed to send stars payment success message: {e_send}") + diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py new file mode 100644 index 0000000..c86493f --- /dev/null +++ b/bot/services/tribute_service.py @@ -0,0 +1,148 @@ +import logging +import hmac +import hashlib +import json +from typing import Optional + +from aiohttp import web +from aiogram import Bot +from sqlalchemy.orm import sessionmaker + +from config.settings import Settings +from bot.middlewares.i18n import JsonI18n +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService +from bot.services.referral_service import ReferralService +from db.dal import payment_dal, user_dal, subscription_dal + + +class TributeService: + def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, + async_session_factory: sessionmaker, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + referral_service: ReferralService): + self.bot = bot + self.settings = settings + self.i18n = i18n + self.async_session_factory = async_session_factory + self.panel_service = panel_service + self.subscription_service = subscription_service + self.referral_service = referral_service + + async def handle_webhook(self, raw_body: bytes, + signature_header: Optional[str]) -> web.Response: + settings = self.settings + bot = self.bot + i18n = self.i18n + async_session_factory = self.async_session_factory + subscription_service = self.subscription_service + referral_service = self.referral_service + + if settings.TRIBUTE_API_KEY: + if not signature_header: + return web.Response(status=403, text="no_signature") + expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body, + hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected_sig, signature_header): + return web.Response(status=403, text="invalid_signature") + + try: + payload = json.loads(raw_body.decode()) + except Exception: + return web.Response(status=400, text="bad_request") + + event_name = payload.get('name') + data = payload.get('payload', {}) + user_id = data.get('telegram_user_id') + price_val = data.get('price') + + if not user_id or price_val is None: + return web.Response(status=200, text="ok_missing_fields") + + months_map = {int(v): m for m, v in settings.subscription_options.items()} + price_rub = price_val / 100 + months = months_map.get(int(price_rub)) + if not months: + logging.warning( + f"Tribute webhook: price {price_val} not mapped to months") + return web.Response(status=200, text="ok_price_unmapped") + + async with async_session_factory() as session: + if event_name == 'new_subscription': + payment_record = await payment_dal.create_payment_record( + session, + { + 'user_id': user_id, + 'amount': float(price_rub), + 'currency': 'RUB', + 'status': 'succeeded', + 'description': 'Tribute subscription', + 'subscription_duration_months': months, + 'provider_payment_id': str(data.get('subscription_id')), + 'provider': 'tribute', + }, + ) + activation_details = await subscription_service.activate_subscription( + session, + user_id, + months, + float(price_rub), + payment_record.payment_id, + provider='tribute', + ) + referral_bonus = await referral_service.apply_referral_bonuses_for_payment( + session, user_id, months) + await session.commit() + + db_user = await user_dal.get_user_by_id(session, user_id) + lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: i18n.gettext(lang, k, **kw) + + applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None + final_end = (referral_bonus.get('referee_new_end_date') + if referral_bonus else None) + if not final_end: + final_end = activation_details.get('end_date') + + if final_end: + if applied_ref_days: + inviter_name_display = _('friend_placeholder') + if db_user and db_user.referred_by_id: + inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) + if inviter and inviter.first_name: + inviter_name_display = inviter.first_name + elif inviter and inviter.username: + inviter_name_display = f"@{inviter.username}" + success_msg = _( + "payment_successful_with_referral_bonus", + months=months, + base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), + bonus_days=applied_ref_days, + final_end_date=final_end.strftime('%Y-%m-%d'), + inviter_name=inviter_name_display) + else: + success_msg = _( + "payment_successful", months=months, + end_date=final_end.strftime('%Y-%m-%d')) + + try: + await bot.send_message(user_id, success_msg) + except Exception as e: + logging.error( + f"Failed to send Tribute payment success message to user {user_id}: {e}") + elif event_name == 'cancelled_subscription': + db_user = await user_dal.get_user_by_id(session, user_id) + lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: i18n.gettext(lang, k, **kw) + try: + await bot.send_message(user_id, _("subscription_cancelled_notification")) + except Exception as e: + logging.warning( + f"Failed to notify user {user_id} about cancellation: {e}") + await subscription_dal.set_skip_notifications_for_provider( + session, user_id, 'tribute', False) + await session.commit() + else: + await session.commit() + return web.Response(status=200, text="ok") diff --git a/bot/services/payment_service.py b/bot/services/yookassa_service.py similarity index 100% rename from bot/services/payment_service.py rename to bot/services/yookassa_service.py