From aa20ac9a1888e22c5b94683ed3442d52fa808f0e Mon Sep 17 00:00:00 2001 From: Machka Pasla <161734431+machka-pasla@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:42:24 +0300 Subject: [PATCH 1/4] Notify users after Tribute payment --- .env.example | 14 +- bot/handlers/user/payment.py | 3 +- bot/handlers/user/subscription.py | 189 ++++++++++++++++++++++--- bot/handlers/webhooks/__init__.py | 0 bot/handlers/webhooks/tribute.py | 127 +++++++++++++++++ bot/keyboards/inline/user_keyboards.py | 20 +-- bot/main_bot.py | 9 ++ bot/services/subscription_service.py | 7 +- config/settings.py | 60 +++++++- db/dal/payment_dal.py | 20 +++ db/dal/subscription_dal.py | 15 +- db/models.py | 4 + locales/en.json | 7 +- locales/ru.json | 7 +- 14 files changed, 445 insertions(+), 37 deletions(-) create mode 100644 bot/handlers/webhooks/__init__.py create mode 100644 bot/handlers/webhooks/tribute.py diff --git a/.env.example b/.env.example index 7213a66..b0f8c21 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,19 @@ TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld PRICE_1_MONTH=150 PRICE_3_MONTHS=300 PRICE_6_MONTHS=500 -PRICE_12_MONTHS=900 +PRICE_12_MONTHS=900 +# Telegram Stars Prices (integer values) +STARS_PRICE_1_MONTH=0 +STARS_PRICE_3_MONTHS=0 +STARS_PRICE_6_MONTHS=0 +STARS_PRICE_12_MONTHS=0 +# Tribute Payment Links +TRIBUTE_LINK_1_MONTH= +TRIBUTE_LINK_3_MONTHS= +TRIBUTE_LINK_6_MONTHS= +TRIBUTE_LINK_12_MONTHS= +# API key for verifying Tribute webhook signatures +TRIBUTE_API_KEY= # Subscription Expiration Notifications SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7 diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 6063652..efd1a74 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -105,7 +105,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, subscription_months, payment_value, payment_db_id, - promo_code_id_from_payment=promo_code_id) + promo_code_id_from_payment=promo_code_id, + provider="yookassa") if not activation_details or not activation_details.get('end_date'): logging.error( diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 3a3144f..91b0185 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -1,19 +1,20 @@ import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice from typing import Optional, Dict, Any, Union from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from db.dal import payment_dal +from db.dal import payment_dal, user_dal from bot.keyboards.inline.user_keyboards import ( - get_subscription_options_keyboard, get_confirm_subscription_keyboard, + 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.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService +from bot.services.referral_service import ReferralService from bot.middlewares.i18n import JsonI18n router = Router(name="user_subscription_router") @@ -99,28 +100,101 @@ async def select_subscription_period_callback_handler( return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - confirmation_text_content = get_text("confirm_subscription_prompt", - months=months, - price=f"{price_rub:.2f}", - currency_symbol=currency_symbol_val) - reply_markup = get_confirm_subscription_keyboard(months, price_rub, - currency_symbol_val, - current_lang, i18n) + text_content = get_text("choose_payment_method") + tribute_url = settings.tribute_payment_links.get(months) + stars_price = settings.stars_subscription_options.get(months) + reply_markup = get_payment_method_keyboard( + months, + price_rub, + tribute_url, + stars_price, + currency_symbol_val, + current_lang, + i18n, + ) try: - await callback.message.edit_text(confirmation_text_content, + await callback.message.edit_text(text_content, reply_markup=reply_markup) except Exception as e_edit: logging.warning( - f"Edit message for subscription confirmation failed: {e_edit}. Sending new one." + f"Edit message for payment method selection failed: {e_edit}. Sending new one." ) - await callback.message.answer(confirmation_text_content, + await callback.message.answer(text_content, reply_markup=reply_markup) await callback.answer() -@router.callback_query(F.data.startswith("confirm_sub:")) -async def confirm_subscription_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): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + return + + try: + _, data_payload = callback.data.split(":", 1) + months_str, price_str = data_payload.split(":") + months = int(months_str) + stars_price = int(price_str) + except (ValueError, IndexError): + logging.error(f"Invalid pay_stars data in callback: {callback.data}") + await callback.answer(get_text("error_try_again"), show_alert=True) + return + + 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) + await callback.message.edit_text(get_text("error_payment_gateway")) + await callback.answer(get_text("error_try_again"), show_alert=True) + return + + await callback.answer() + + +@router.callback_query(F.data.startswith("pay_yk:")) +async def pay_yk_callback_handler( callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -151,7 +225,7 @@ async def confirm_subscription_callback_handler( price_rub = float(price_str) except (ValueError, IndexError): logging.error( - f"Invalid confirmation data in callback: {callback.data}") + f"Invalid pay_yk data in callback: {callback.data}") await callback.answer(get_text("error_try_again"), show_alert=True) return @@ -342,6 +416,89 @@ async def my_subscription_command_handler( await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) +@router.pre_checkout_query() +async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery): + await pre_checkout_query.answer(ok=True) + + +@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): + sp = message.successful_payment + if not sp or sp.currency != "XTR": + return + + payload = sp.invoice_payload or "" + try: + payment_id_str, months_str = payload.split(":") + payment_db_id = int(payment_id_str) + months = int(months_str) + except (ValueError, IndexError): + 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 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}") + + @router.message(Command("connect")) async def connect_command_handler(message: types.Message, i18n_data: dict, settings: Settings, diff --git a/bot/handlers/webhooks/__init__.py b/bot/handlers/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/webhooks/tribute.py b/bot/handlers/webhooks/tribute.py new file mode 100644 index 0000000..90c6c4c --- /dev/null +++ b/bot/handlers/webhooks/tribute.py @@ -0,0 +1,127 @@ +import logging +import hmac +import hashlib +import json +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 + +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'] + + 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 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/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 920ccec..6e1cc3d 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -108,16 +108,20 @@ def get_subscription_options_keyboard(subscription_options: Dict[ return builder.as_markup() -def get_confirm_subscription_keyboard(months: int, price: float, - currency_symbol_val: str, lang: str, - i18n_instance) -> InlineKeyboardMarkup: +def get_payment_method_keyboard(months: int, price: float, + tribute_url: Optional[str], + stars_price: Optional[int], + currency_symbol_val: str, lang: str, + i18n_instance) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - confirm_text = _(key="confirm_payment_button", - price=price, - currency_symbol=currency_symbol_val) - builder.button(text=confirm_text, - callback_data=f"confirm_sub:{months}:{price}") + if stars_price is not None: + builder.button(text=_("pay_with_stars_button"), + callback_data=f"pay_stars:{months}:{stars_price}") + if tribute_url: + builder.button(text=_("pay_with_tribute_button"), url=tribute_url) + builder.button(text=_("pay_with_yookassa_button"), + callback_data=f"pay_yk:{months}:{price}") builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") builder.adjust(1) diff --git a/bot/main_bot.py b/bot/main_bot.py index 5995711..9071663 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -33,6 +33,7 @@ from bot.services.referral_service import ReferralService from bot.services.promo_code_service import PromoCodeService from bot.handlers.user import payment as user_payment_webhook_module +from bot.handlers.webhooks import tribute as tribute_webhook_module class DBSessionMiddleware(BaseMiddleware): @@ -327,6 +328,14 @@ async def run_bot(settings_param: Settings): logging.info( f"YooKassa webhook route configured at: [POST] {yk_path}") + tribute_path = settings_param.tribute_webhook_path + if tribute_path.startswith('/'): + app.router.add_post( + tribute_path, + tribute_webhook_module.tribute_webhook_route) + logging.info( + f"Tribute webhook route configured at: [POST] {tribute_path}") + web_app_runner = web.AppRunner(app) await web_app_runner.setup() site = web.TCPSite(web_app_runner, diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 4574c58..d9fb834 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -318,7 +318,8 @@ class SubscriptionService: months: int, payment_amount: float, payment_db_id: int, - promo_code_id_from_payment: Optional[int] = None + promo_code_id_from_payment: Optional[int] = None, + provider: str = "yookassa" ) -> Optional[Dict[str, Any]]: db_user = await user_dal.get_user_by_id(session, user_id) @@ -386,6 +387,8 @@ class SubscriptionService: "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES, + "provider": provider, + "skip_notifications": provider == "tribute", } try: new_or_updated_sub = await subscription_dal.upsert_subscription( @@ -601,7 +604,7 @@ class SubscriptionService: session, days_threshold) results = [] for sub_model in subs_models_with_users: - if sub_model.user and sub_model.end_date: + if sub_model.user and sub_model.end_date and not sub_model.skip_notifications: days_left = (sub_model.end_date - datetime.now( timezone.utc)).total_seconds() / (24 * 3600) results.append({ diff --git a/config/settings.py b/config/settings.py index a00a948..7463c41 100644 --- a/config/settings.py +++ b/config/settings.py @@ -41,6 +41,18 @@ class Settings(BaseSettings): PRICE_6_MONTHS: Optional[int] = Field(default=None) PRICE_12_MONTHS: Optional[int] = Field(default=None) + STARS_PRICE_1_MONTH: Optional[int] = Field(default=None) + STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None) + STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None) + STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None) + + + TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None) + TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None) + TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None) + TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None) + TRIBUTE_API_KEY: Optional[str] = Field(default=None) + SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7) SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9) SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0) @@ -141,21 +153,61 @@ class Settings(BaseSettings): return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}" return None + @computed_field + @property + def tribute_webhook_path(self) -> str: + return "/webhook/tribute" + + @computed_field + @property + def tribute_full_webhook_url(self) -> Optional[str]: + if self.YOOKASSA_WEBHOOK_BASE_URL: + return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.tribute_webhook_path}" + return None + @computed_field @property def subscription_options(self) -> Dict[int, float]: options: Dict[int, float] = {} if self.PRICE_1_MONTH is not None: - options[1] = float(self.PRICE_1_MONTH / 100.0) + options[1] = float(self.PRICE_1_MONTH) if self.PRICE_3_MONTHS is not None: - options[3] = float(self.PRICE_3_MONTHS / 100.0) + options[3] = float(self.PRICE_3_MONTHS) if self.PRICE_6_MONTHS is not None: - options[6] = float(self.PRICE_6_MONTHS / 100.0) + options[6] = float(self.PRICE_6_MONTHS) if self.PRICE_12_MONTHS is not None: - options[12] = float(self.PRICE_12_MONTHS / 100.0) + options[12] = float(self.PRICE_12_MONTHS) return options + @computed_field + @property + def stars_subscription_options(self) -> Dict[int, int]: + options: Dict[int, int] = {} + if self.STARS_PRICE_1_MONTH is not None: + options[1] = self.STARS_PRICE_1_MONTH + if self.STARS_PRICE_3_MONTHS is not None: + options[3] = self.STARS_PRICE_3_MONTHS + if self.STARS_PRICE_6_MONTHS is not None: + options[6] = self.STARS_PRICE_6_MONTHS + if self.STARS_PRICE_12_MONTHS is not None: + options[12] = self.STARS_PRICE_12_MONTHS + return options + + @computed_field + @property + def tribute_payment_links(self) -> Dict[int, str]: + links: Dict[int, str] = {} + if self.TRIBUTE_LINK_1_MONTH: + links[1] = self.TRIBUTE_LINK_1_MONTH + if self.TRIBUTE_LINK_3_MONTHS: + links[3] = self.TRIBUTE_LINK_3_MONTHS + if self.TRIBUTE_LINK_6_MONTHS: + links[6] = self.TRIBUTE_LINK_6_MONTHS + if self.TRIBUTE_LINK_12_MONTHS: + links[12] = self.TRIBUTE_LINK_12_MONTHS + return links + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 47cb147..f37e6e4 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -113,3 +113,23 @@ async def get_recent_payment_logs_with_user(session: AsyncSession, Payment.created_at.desc()).limit(limit).offset(offset)) result = await session.execute(stmt) return result.scalars().all() + + +async def update_provider_payment_and_status( + session: AsyncSession, payment_db_id: int, + provider_payment_id: str, new_status: str) -> Optional[Payment]: + payment = await get_payment_by_db_id(session, payment_db_id) + if payment: + payment.status = new_status + payment.provider_payment_id = provider_payment_id + payment.updated_at = func.now() + await session.flush() + await session.refresh(payment) + logging.info( + f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." + ) + else: + logging.warning( + f"Payment record with DB ID {payment_db_id} not found for provider update." + ) + return payment diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index c621cfa..f14b3b3 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -156,7 +156,9 @@ async def get_subscriptions_near_expiration( threshold_date = now_utc + timedelta(days=days_threshold) stmt = (select(Subscription).join(Subscription.user).where( - Subscription.is_active == True, Subscription.end_date > now_utc, + Subscription.is_active == True, + Subscription.skip_notifications == False, + Subscription.end_date > now_utc, Subscription.end_date <= threshold_date, or_( Subscription.last_notification_sent == None, @@ -203,3 +205,14 @@ async def find_subscription_for_notification_update( <= subscription_end_date_to_match + timedelta(seconds=1)).limit(1) result = await session.execute(stmt) return result.scalar_one_or_none() + + +async def set_skip_notifications_for_provider( + session: AsyncSession, user_id: int, provider: str, + skip: bool) -> int: + stmt = (update(Subscription).where( + Subscription.user_id == user_id, + Subscription.is_active == True, + Subscription.provider == provider).values(skip_notifications=skip)) + result = await session.execute(stmt) + return result.rowcount diff --git a/db/models.py b/db/models.py index 72695f3..c8e2c4a 100644 --- a/db/models.py +++ b/db/models.py @@ -70,6 +70,8 @@ class Subscription(Base): traffic_limit_bytes = Column(BigInteger, nullable=True) traffic_used_bytes = Column(BigInteger, nullable=True) last_notification_sent = Column(DateTime(timezone=True), nullable=True) + provider = Column(String, nullable=True) + skip_notifications = Column(Boolean, default=False) user = relationship("User", back_populates="subscriptions") @@ -89,6 +91,8 @@ class Payment(Base): unique=True, index=True, nullable=True) + provider_payment_id = Column(String, unique=True, nullable=True) + provider = Column(String, nullable=False, default="yookassa", index=True) idempotence_key = Column(String, unique=True, nullable=True) amount = Column(Float, nullable=False) currency = Column(String, nullable=False) diff --git a/locales/en.json b/locales/en.json index a7c9a4c..f20c452 100644 --- a/locales/en.json +++ b/locales/en.json @@ -22,9 +22,11 @@ "select_subscription_period": "Select subscription period:", "no_subscription_options_available": "No subscription options available at the moment.", "subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}", - "confirm_subscription_prompt": "Confirm subscription purchase:\nDuration: {months} mo.\nPrice: {price} {currency_symbol}", + "choose_payment_method": "Choose payment method:", "pay_button": "💳 Pay", - "confirm_payment_button": "✅ Yes ({price} {currency_symbol})", + "pay_with_yookassa_button": "💳 YooKassa", + "pay_with_tribute_button": "❤️ Tribute", + "pay_with_stars_button": "🌟 Telegram Stars", "cancel_button": "❌ Cancel", "payment_description_subscription": "Subscription payment for {months} mo.", "payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.", @@ -201,6 +203,7 @@ "stub_page_display": "Page", "subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.", + "subscription_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.", "error_unknown": "An unknown error occurred." } diff --git a/locales/ru.json b/locales/ru.json index 7f3045e..5e852f6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -22,9 +22,11 @@ "select_subscription_period": "Выберите срок подписки:", "no_subscription_options_available": "В данный момент нет доступных вариантов подписки.", "subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}", - "confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}", + "choose_payment_method": "Выберите способ оплаты:", "pay_button": "💳 Оплатить", - "confirm_payment_button": "✅ Да ({price} {currency_symbol})", + "pay_with_yookassa_button": "💳 ЮKassa", + "pay_with_tribute_button": "❤️ Tribute", + "pay_with_stars_button": "🌟 Звезды Telegram", "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", "payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.", @@ -201,6 +203,7 @@ "stub_page_display": "Страница", "subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.", + "subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.", "error_unknown": "Произошла неизвестная ошибка." } From cb82637b8ed85896024da4d4988ba7fab0ed0f78 Mon Sep 17 00:00:00 2001 From: Machka Pasla <161734431+machka-pasla@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:59:17 +0300 Subject: [PATCH 2/4] Fix payment notifications --- bot/handlers/user/subscription.py | 5 ++++- bot/handlers/webhooks/tribute.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 91b0185..ea1423c 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -468,7 +468,10 @@ async def stars_successful_payment_handler( 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 activation_details["end_date"] + 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") diff --git a/bot/handlers/webhooks/tribute.py b/bot/handlers/webhooks/tribute.py index 90c6c4c..e61f075 100644 --- a/bot/handlers/webhooks/tribute.py +++ b/bot/handlers/webhooks/tribute.py @@ -86,7 +86,10 @@ async def tribute_webhook_route(request: web.Request): _ = 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 activation_details.get('end_date') + 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: From 6e8f810cbd6b972e22c9043f6ab9ee38e917ed1e Mon Sep 17 00:00:00 2001 From: Machka Pasla <161734431+machka-pasla@users.noreply.github.com> Date: Tue, 24 Jun 2025 01:18:30 +0300 Subject: [PATCH 3/4] feat: add payment method toggles and new pricing vars --- .env.example | 30 ++++++++++------ README.md | 5 ++- bot/handlers/user/subscription.py | 1 + bot/keyboards/inline/user_keyboards.py | 11 +++--- config/settings.py | 49 +++++++++++++++----------- 5 files changed, 60 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index b0f8c21..dbe84f0 100644 --- a/.env.example +++ b/.env.example @@ -31,20 +31,30 @@ YOOKASSA_PAYMENT_SUBJECT=payment # If unset, bot will use polling while YooKassa uses webhooks. TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld -# Subscription Prices (integer values) -PRICE_1_MONTH=150 -PRICE_3_MONTHS=300 -PRICE_6_MONTHS=500 -PRICE_12_MONTHS=900 -# Telegram Stars Prices (integer values) +# Payment Methods +YOOKASSA_ENABLED=True +STARS_ENABLED=True +TRIBUTE_ENABLED=True + +# Subscription Options +1_MONTH_ENABLED=True +RUB_PRICE_1_MONTH=150 STARS_PRICE_1_MONTH=0 -STARS_PRICE_3_MONTHS=0 -STARS_PRICE_6_MONTHS=0 -STARS_PRICE_12_MONTHS=0 -# Tribute Payment Links TRIBUTE_LINK_1_MONTH= + +3_MONTHS_ENABLED=True +RUB_PRICE_3_MONTHS=300 +STARS_PRICE_3_MONTHS=0 TRIBUTE_LINK_3_MONTHS= + +6_MONTHS_ENABLED=True +RUB_PRICE_6_MONTHS=500 +STARS_PRICE_6_MONTHS=0 TRIBUTE_LINK_6_MONTHS= + +12_MONTHS_ENABLED=True +RUB_PRICE_12_MONTHS=900 +STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= # API key for verifying Tribute webhook signatures TRIBUTE_API_KEY= diff --git a/README.md b/README.md index fdc2e00..f98a47c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,10 @@ This Telegram bot is designed to automate the sale and management of subscriptio * `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`. * `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`. * `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates. - * `PRICE_X_MONTH`: Prices for different subscription durations. + * **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`. + * **Subscription Options:** For each duration you can use variables like + `1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH` + (and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`). * **Panel API Settings:** * `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`). * `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel. diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index ea1423c..2bd6045 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -111,6 +111,7 @@ async def select_subscription_period_callback_handler( currency_symbol_val, current_lang, i18n, + settings, ) try: diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 6e1cc3d..e6d5f4c 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -112,16 +112,17 @@ def get_payment_method_keyboard(months: int, price: float, tribute_url: Optional[str], stars_price: Optional[int], currency_symbol_val: str, lang: str, - i18n_instance) -> InlineKeyboardMarkup: + i18n_instance, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - if stars_price is not None: + if settings.STARS_ENABLED and stars_price is not None: builder.button(text=_("pay_with_stars_button"), callback_data=f"pay_stars:{months}:{stars_price}") - if tribute_url: + if settings.TRIBUTE_ENABLED and tribute_url: builder.button(text=_("pay_with_tribute_button"), url=tribute_url) - builder.button(text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{months}:{price}") + if settings.YOOKASSA_ENABLED: + builder.button(text=_("pay_with_yookassa_button"), + callback_data=f"pay_yk:{months}:{price}") builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") builder.adjust(1) diff --git a/config/settings.py b/config/settings.py index 7463c41..b3ab200 100644 --- a/config/settings.py +++ b/config/settings.py @@ -36,10 +36,19 @@ class Settings(BaseSettings): TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None - PRICE_1_MONTH: Optional[int] = Field(default=None) - PRICE_3_MONTHS: Optional[int] = Field(default=None) - PRICE_6_MONTHS: Optional[int] = Field(default=None) - PRICE_12_MONTHS: Optional[int] = Field(default=None) + YOOKASSA_ENABLED: bool = Field(default=True) + STARS_ENABLED: bool = Field(default=True) + TRIBUTE_ENABLED: bool = Field(default=True) + + MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") + MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") + MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED") + MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED") + + RUB_PRICE_1_MONTH: Optional[int] = Field(default=None) + RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None) + RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None) + RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None) STARS_PRICE_1_MONTH: Optional[int] = Field(default=None) STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None) @@ -170,27 +179,27 @@ class Settings(BaseSettings): def subscription_options(self) -> Dict[int, float]: options: Dict[int, float] = {} - if self.PRICE_1_MONTH is not None: - options[1] = float(self.PRICE_1_MONTH) - if self.PRICE_3_MONTHS is not None: - options[3] = float(self.PRICE_3_MONTHS) - if self.PRICE_6_MONTHS is not None: - options[6] = float(self.PRICE_6_MONTHS) - if self.PRICE_12_MONTHS is not None: - options[12] = float(self.PRICE_12_MONTHS) + if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None: + options[1] = float(self.RUB_PRICE_1_MONTH) + if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None: + options[3] = float(self.RUB_PRICE_3_MONTHS) + if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None: + options[6] = float(self.RUB_PRICE_6_MONTHS) + if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None: + options[12] = float(self.RUB_PRICE_12_MONTHS) return options @computed_field @property def stars_subscription_options(self) -> Dict[int, int]: options: Dict[int, int] = {} - if self.STARS_PRICE_1_MONTH is not None: + if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None: options[1] = self.STARS_PRICE_1_MONTH - if self.STARS_PRICE_3_MONTHS is not None: + if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None: options[3] = self.STARS_PRICE_3_MONTHS - if self.STARS_PRICE_6_MONTHS is not None: + if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None: options[6] = self.STARS_PRICE_6_MONTHS - if self.STARS_PRICE_12_MONTHS is not None: + if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None: options[12] = self.STARS_PRICE_12_MONTHS return options @@ -198,13 +207,13 @@ class Settings(BaseSettings): @property def tribute_payment_links(self) -> Dict[int, str]: links: Dict[int, str] = {} - if self.TRIBUTE_LINK_1_MONTH: + if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH: links[1] = self.TRIBUTE_LINK_1_MONTH - if self.TRIBUTE_LINK_3_MONTHS: + if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS: links[3] = self.TRIBUTE_LINK_3_MONTHS - if self.TRIBUTE_LINK_6_MONTHS: + if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS: links[6] = self.TRIBUTE_LINK_6_MONTHS - if self.TRIBUTE_LINK_12_MONTHS: + if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS: links[12] = self.TRIBUTE_LINK_12_MONTHS return links From eea6afbcc7e61a594b5737d44c6ff61515b923dd Mon Sep 17 00:00:00 2001 From: Machka Pasla <161734431+machka-pasla@users.noreply.github.com> Date: Tue, 24 Jun 2025 01:35:20 +0300 Subject: [PATCH 4/4] Refactor payment logic into dedicated services --- .env.example | 30 ++-- README.md | 5 +- bot/handlers/user/payment.py | 2 +- bot/handlers/user/subscription.py | 111 ++----------- bot/handlers/webhooks/tribute.py | 127 +-------------- bot/keyboards/inline/user_keyboards.py | 11 +- bot/main_bot.py | 14 +- bot/services/stars_service.py | 135 ++++++++++++++++ bot/services/tribute_service.py | 148 ++++++++++++++++++ ...payment_service.py => yookassa_service.py} | 0 config/settings.py | 49 +++--- 11 files changed, 370 insertions(+), 262 deletions(-) create mode 100644 bot/services/stars_service.py create mode 100644 bot/services/tribute_service.py rename bot/services/{payment_service.py => yookassa_service.py} (100%) diff --git a/.env.example b/.env.example index b0f8c21..dbe84f0 100644 --- a/.env.example +++ b/.env.example @@ -31,20 +31,30 @@ YOOKASSA_PAYMENT_SUBJECT=payment # If unset, bot will use polling while YooKassa uses webhooks. TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld -# Subscription Prices (integer values) -PRICE_1_MONTH=150 -PRICE_3_MONTHS=300 -PRICE_6_MONTHS=500 -PRICE_12_MONTHS=900 -# Telegram Stars Prices (integer values) +# Payment Methods +YOOKASSA_ENABLED=True +STARS_ENABLED=True +TRIBUTE_ENABLED=True + +# Subscription Options +1_MONTH_ENABLED=True +RUB_PRICE_1_MONTH=150 STARS_PRICE_1_MONTH=0 -STARS_PRICE_3_MONTHS=0 -STARS_PRICE_6_MONTHS=0 -STARS_PRICE_12_MONTHS=0 -# Tribute Payment Links TRIBUTE_LINK_1_MONTH= + +3_MONTHS_ENABLED=True +RUB_PRICE_3_MONTHS=300 +STARS_PRICE_3_MONTHS=0 TRIBUTE_LINK_3_MONTHS= + +6_MONTHS_ENABLED=True +RUB_PRICE_6_MONTHS=500 +STARS_PRICE_6_MONTHS=0 TRIBUTE_LINK_6_MONTHS= + +12_MONTHS_ENABLED=True +RUB_PRICE_12_MONTHS=900 +STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= # API key for verifying Tribute webhook signatures TRIBUTE_API_KEY= diff --git a/README.md b/README.md index fdc2e00..f98a47c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,10 @@ This Telegram bot is designed to automate the sale and management of subscriptio * `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`. * `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`. * `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates. - * `PRICE_X_MONTH`: Prices for different subscription durations. + * **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`. + * **Subscription Options:** For each duration you can use variables like + `1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH` + (and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`). * **Panel API Settings:** * `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`). * `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel. 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 ea1423c..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 @@ -111,6 +112,7 @@ async def select_subscription_period_callback_handler( currency_symbol_val, current_lang, i18n, + settings, ) try: @@ -128,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") @@ -151,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 @@ -424,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 @@ -439,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/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 6e1cc3d..e6d5f4c 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -112,16 +112,17 @@ def get_payment_method_keyboard(months: int, price: float, tribute_url: Optional[str], stars_price: Optional[int], currency_symbol_val: str, lang: str, - i18n_instance) -> InlineKeyboardMarkup: + i18n_instance, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - if stars_price is not None: + if settings.STARS_ENABLED and stars_price is not None: builder.button(text=_("pay_with_stars_button"), callback_data=f"pay_stars:{months}:{stars_price}") - if tribute_url: + if settings.TRIBUTE_ENABLED and tribute_url: builder.button(text=_("pay_with_tribute_button"), url=tribute_url) - builder.button(text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{months}:{price}") + if settings.YOOKASSA_ENABLED: + builder.button(text=_("pay_with_yookassa_button"), + callback_data=f"pay_yk:{months}:{price}") builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") builder.adjust(1) 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 diff --git a/config/settings.py b/config/settings.py index 7463c41..b3ab200 100644 --- a/config/settings.py +++ b/config/settings.py @@ -36,10 +36,19 @@ class Settings(BaseSettings): TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None - PRICE_1_MONTH: Optional[int] = Field(default=None) - PRICE_3_MONTHS: Optional[int] = Field(default=None) - PRICE_6_MONTHS: Optional[int] = Field(default=None) - PRICE_12_MONTHS: Optional[int] = Field(default=None) + YOOKASSA_ENABLED: bool = Field(default=True) + STARS_ENABLED: bool = Field(default=True) + TRIBUTE_ENABLED: bool = Field(default=True) + + MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") + MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") + MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED") + MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED") + + RUB_PRICE_1_MONTH: Optional[int] = Field(default=None) + RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None) + RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None) + RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None) STARS_PRICE_1_MONTH: Optional[int] = Field(default=None) STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None) @@ -170,27 +179,27 @@ class Settings(BaseSettings): def subscription_options(self) -> Dict[int, float]: options: Dict[int, float] = {} - if self.PRICE_1_MONTH is not None: - options[1] = float(self.PRICE_1_MONTH) - if self.PRICE_3_MONTHS is not None: - options[3] = float(self.PRICE_3_MONTHS) - if self.PRICE_6_MONTHS is not None: - options[6] = float(self.PRICE_6_MONTHS) - if self.PRICE_12_MONTHS is not None: - options[12] = float(self.PRICE_12_MONTHS) + if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None: + options[1] = float(self.RUB_PRICE_1_MONTH) + if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None: + options[3] = float(self.RUB_PRICE_3_MONTHS) + if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None: + options[6] = float(self.RUB_PRICE_6_MONTHS) + if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None: + options[12] = float(self.RUB_PRICE_12_MONTHS) return options @computed_field @property def stars_subscription_options(self) -> Dict[int, int]: options: Dict[int, int] = {} - if self.STARS_PRICE_1_MONTH is not None: + if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None: options[1] = self.STARS_PRICE_1_MONTH - if self.STARS_PRICE_3_MONTHS is not None: + if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None: options[3] = self.STARS_PRICE_3_MONTHS - if self.STARS_PRICE_6_MONTHS is not None: + if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None: options[6] = self.STARS_PRICE_6_MONTHS - if self.STARS_PRICE_12_MONTHS is not None: + if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None: options[12] = self.STARS_PRICE_12_MONTHS return options @@ -198,13 +207,13 @@ class Settings(BaseSettings): @property def tribute_payment_links(self) -> Dict[int, str]: links: Dict[int, str] = {} - if self.TRIBUTE_LINK_1_MONTH: + if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH: links[1] = self.TRIBUTE_LINK_1_MONTH - if self.TRIBUTE_LINK_3_MONTHS: + if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS: links[3] = self.TRIBUTE_LINK_3_MONTHS - if self.TRIBUTE_LINK_6_MONTHS: + if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS: links[6] = self.TRIBUTE_LINK_6_MONTHS - if self.TRIBUTE_LINK_12_MONTHS: + if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS: links[12] = self.TRIBUTE_LINK_12_MONTHS return links