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] 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": "Произошла неизвестная ошибка." }