diff --git a/README.md b/README.md index d97410e..5f10ac1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. - - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay, Telegram Stars и Tribute. + - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -80,7 +80,7 @@ | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | - | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `tribute`, `stars`, `cryptopay`. Первый будет сверху. | + | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. | | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | @@ -96,7 +96,6 @@ | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | | `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | - | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | | `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). | | `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. | | `PLATEGA_SECRET`| API секрет для запросов Platega. | @@ -118,7 +117,6 @@ - `1_MONTH_ENABLED`: `true` или `false` - `RUB_PRICE_1_MONTH`: Цена в рублях - `STARS_PRICE_1_MONTH`: Цена в Telegram Stars - - `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`. @@ -155,7 +153,7 @@ Эта команда скачает образ и запустит сервис в фоновом режиме. 4. **Настройка вебхуков (Обязательно):** - Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave. + Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave. Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. @@ -165,7 +163,6 @@ - `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:/webhook/platega` - `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:/webhook/severpay` - `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:/webhook/cryptopay` - - `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:/webhook/tribute` - `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:/webhook/panel` - **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/`. diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 394bb0f..af034b0 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -9,7 +9,6 @@ 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.services.crypto_pay_service import CryptoPayService from bot.services.panel_webhook_service import PanelWebhookService from bot.services.freekassa_service import FreeKassaService @@ -47,15 +46,6 @@ def build_core_services( subscription_service=subscription_service, referral_service=referral_service, ) - tribute_service = TributeService( - bot, - settings, - i18n, - async_session_factory, - panel_service, - subscription_service, - referral_service, - ) platega_service = PlategaService( bot=bot, settings=settings, @@ -100,7 +90,6 @@ def build_core_services( "stars_service": stars_service, "cryptopay_service": cryptopay_service, "freekassa_service": freekassa_service, - "tribute_service": tribute_service, "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, "platega_service": platega_service, diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index c9745e9..f3cfdbe 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -29,7 +29,6 @@ async def build_and_start_web_app( "stars_service", "freekassa_service", "cryptopay_service", - "tribute_service", "panel_webhook_service", "platega_service", "severpay_service", @@ -50,18 +49,12 @@ async def build_and_start_web_app( ) from bot.handlers.user.payment import yookassa_webhook_route - from bot.services.tribute_service import tribute_webhook_route from bot.services.crypto_pay_service import cryptopay_webhook_route from bot.services.panel_webhook_service import panel_webhook_route from bot.services.freekassa_service import freekassa_webhook_route from bot.services.platega_service import platega_webhook_route from bot.services.severpay_service import severpay_webhook_route - tribute_path = settings.tribute_webhook_path - if tribute_path.startswith("/"): - app.router.add_post(tribute_path, tribute_webhook_route) - logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}") - cp_path = settings.cryptopay_webhook_path if cp_path.startswith("/"): app.router.add_post(cp_path, cryptopay_webhook_route) diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 36fb4e7..ee0996f 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -60,7 +60,6 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: provider_text = { 'yookassa': 'YooKassa', - 'tribute': 'Tribute', 'telegram_stars': 'Telegram Stars', 'cryptopay': 'CryptoPay', 'freekassa': 'FreeKassa', diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 3cec154..ef3e480 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -125,17 +125,6 @@ async def my_subscription_command_handler( end_date = active.get("end_date") days_left = (end_date.date() - datetime.now().date()).days if end_date else 0 - tribute_hint = "" - if active.get("status_from_panel", "").lower() == "active": - local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) - if local_sub: - if local_sub.provider == "tribute": - link = None - link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None - tribute_hint = "\n\n" + ( - get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice") - ) - text = get_text( "my_subscription_details", end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", @@ -224,8 +213,8 @@ async def my_subscription_command_handler( ) ]) - # 2) Auto-renew toggle (if supported and not tribute) - if local_sub and local_sub.provider != "tribute" and settings.yookassa_autopayments_active: + # 2) Auto-renew toggle (YooKassa only) + if local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -254,17 +243,17 @@ async def my_subscription_command_handler( except Exception: pass try: - await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) except Exception: await bot.send_message( chat_id=target.chat.id, - text=text + tribute_hint, + text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True, ) else: - await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) @router.callback_query(F.data == "main_action:my_devices") @@ -452,8 +441,8 @@ async def toggle_autorenew_handler( if not sub or sub.user_id != callback.from_user.id: await callback.answer(get_text("error_try_again"), show_alert=True) return - if sub.provider == "tribute": - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + if sub.provider != "yookassa": + await callback.answer(get_text("error_try_again"), show_alert=True) return if enable: has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) @@ -510,8 +499,8 @@ async def confirm_autorenew_handler( if not sub or sub.user_id != callback.from_user.id: await callback.answer(get_text("error_try_again"), show_alert=True) return - if sub.provider == "tribute": - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + if sub.provider != "yookassa": + await callback.answer(get_text("error_try_again"), show_alert=True) return if enable: has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) @@ -549,7 +538,7 @@ async def autorenew_cancel_from_webhook_button( i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - # Disable auto-renew on the active subscription (non-tribute) + # Disable auto-renew on the active subscription from db.dal import subscription_dal sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id) if not sub: @@ -558,9 +547,9 @@ async def autorenew_cancel_from_webhook_button( except Exception: pass return - if sub.provider == "tribute": + if sub.provider != "yookassa": try: - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 7116d18..0d7592e 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -52,12 +52,10 @@ async def select_subscription_period_callback_handler( currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL 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, diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 333464d..cbe51e1 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -112,7 +112,6 @@ def get_subscription_options_keyboard(subscription_options: Dict[ 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, settings: Settings) -> InlineKeyboardMarkup: @@ -139,8 +138,6 @@ def get_payment_method_keyboard(months: int, price: float, text=_("pay_with_yookassa_button"), callback_data=f"pay_yk:{months}:{price}", ) - elif method == "tribute" and settings.TRIBUTE_ENABLED and tribute_url: - builder.button(text=_("pay_with_tribute_button"), url=tribute_url) elif method == "stars" and settings.STARS_ENABLED and stars_price is not None: builder.button( text=_("pay_with_stars_button"), diff --git a/bot/main_bot.py b/bot/main_bot.py index a91b55b..02be218 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -33,7 +33,6 @@ 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, tribute_webhook_route from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route from bot.handlers.user import payment as user_payment_webhook_module @@ -200,7 +199,6 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "panel_service", "cryptopay_service", "freekassa_service", - "tribute_service", "panel_webhook_service", "yookassa_service", "promo_code_service", diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index a4ef9bf..99a6290 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -240,7 +240,6 @@ class NotificationService: "freekassa": "💳", "cryptopay": "₿", "stars": "⭐", - "tribute": "💎", "platega": "💳", "severpay": "💳", }.get(payment_provider.lower(), "💰") diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index 1910676..cee1320 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -12,7 +12,6 @@ from .panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard from db.dal import user_dal -from bot.utils.date_utils import add_months EVENT_MAP = { "user.expires_in_72_hours": (3, "subscription_72h_notification"), @@ -44,128 +43,6 @@ class PanelWebhookService: except Exception as e: logging.error(f"Failed to send notification to {user_id}: {e}") - async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict, - lang: str, markup, first_name: str) -> bool: - """Handle expired subscription - auto-renew tribute users if no cancellation was received. - - Returns True if an auto-renewal was performed (and renewal message sent), False otherwise. - """ - from db.dal import subscription_dal, payment_dal - from datetime import datetime, timezone - - try: - auto_renewed = False - # Check if user has tribute subscriptions that weren't cancelled - user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) - - for sub in user_subs: - # Check if this subscription was marked as cancelled (from tribute cancellation webhook) - if sub.status_from_panel == 'CANCELLED': - logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal") - continue - - # Check if this user has tribute payments - last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id) - - if last_tribute_duration is not None: - # This user has tribute payments, auto-renew for the same duration - logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months") - - # Extend subscription by the last payment duration (calendar months) - new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration) - - # Update local DB subscription - await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - 'end_date': new_end_date, - 'status_from_panel': 'ACTIVE', - 'is_active': True - } - ) - # Update panel expiry to ensure actual service access is extended - try: - panel_payload = { - "uuid": sub.panel_user_uuid, - "expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'), - "status": "ACTIVE", - } - panel_update_resp = await self.panel_service.update_user_details_on_panel( - sub.panel_user_uuid, - panel_payload, - log_response=True, - ) - if panel_update_resp: - logging.info( - f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}" - ) - except Exception as e_panel: - logging.error( - f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}") - - # Create a succeeded payment record in DB with the same amount/currency as last tribute payment - try: - last_payment = await payment_dal.get_last_tribute_payment(session, user_id) - if last_payment and last_payment.amount and last_payment.currency: - provider_payment_id = ( - f"tribute_auto_{user_id}_{sub.subscription_id}_" - f"{new_end_date.strftime('%Y%m%d')}" - ) - created_payment = await payment_dal.ensure_payment_with_provider_id( - session, - user_id=user_id, - amount=float(last_payment.amount), - currency=last_payment.currency, - months=last_tribute_duration, - description="Auto-renewal (panel webhook)", - provider="tribute", - provider_payment_id=provider_payment_id, - ) - if created_payment: - logging.info( - f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}" - ) - else: - logging.warning( - f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency") - except Exception as e_pay: - logging.error( - f"Failed to create auto-renew payment record for user {user_id}: {e_pay}", - exc_info=True, - ) - - # Send auto-renewal notification - _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k - auto_renewal_msg = _( - "tribute_auto_renewal", - default="🔄 Подписка автоматически продлена\n\n" - "Ваша подписка Tribute была автоматически продлена на {months} мес.\n" - "Новая дата окончания: {end_date}", - user_name=first_name, - months=last_tribute_duration, - end_date=new_end_date.strftime('%Y-%m-%d') - ) - - try: - await self.bot.send_message( - user_id, - auto_renewal_msg, - reply_markup=markup, - parse_mode="HTML" - ) - auto_renewed = True - except Exception as e: - logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}") - - await session.commit() - return auto_renewed - - except Exception as e: - logging.error(f"Error handling expired subscription for user {user_id}: {e}") - await session.rollback() - return False - async def handle_event(self, event_name: str, user_payload: dict): telegram_id = user_payload.get("telegramId") if not telegram_id: @@ -193,7 +70,7 @@ class PanelWebhookService: async with self.async_session_factory() as session: from db.dal import subscription_dal sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) - if sub and sub.auto_renew_enabled and sub.provider != 'tribute': + if sub and sub.auto_renew_enabled and sub.provider == 'yookassa': try: ok = await subscription_service.charge_subscription_renewal(session, sub) # If initiation succeeded, suppress the 24h reminder by returning early @@ -208,7 +85,7 @@ class PanelWebhookService: except Exception: logging.exception("Auto-renew trigger (24h) failed pre-check") if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: - # For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button + # For 48h event, if auto-renew is enabled, show special notice with cancel button if days_left == 2: async with self.async_session_factory() as session: from db.dal import subscription_dal @@ -220,7 +97,7 @@ class PanelWebhookService: getattr(sub, 'auto_renew_enabled', None) if sub else None, getattr(sub, 'provider', None) if sub else None, ) - if sub and sub.auto_renew_enabled and sub.provider != 'tribute': + if sub and sub.auto_renew_enabled and sub.provider == 'yookassa': cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n) await self._send_message( user_id, @@ -239,11 +116,7 @@ class PanelWebhookService: end_date=user_payload.get("expireAt", "")[:10], ) elif event_name == "user.expired": - # Check if this is a tribute user that should be auto-renewed (regardless of notification settings) - auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name) - - # If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled. - if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: await self._send_message( user_id, lang, diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 8cd0cba..f8a5303 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -517,7 +517,7 @@ class SubscriptionService: "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "provider": provider, - "skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS, + "skip_notifications": False, "auto_renew_enabled": auto_renew_should_enable, } try: @@ -828,8 +828,8 @@ class SubscriptionService: # If autopayments are disabled globally, skip charging attempts if not self.settings.yookassa_autopayments_active: return True - if sub.provider == "tribute": - # Tribute is paid externally; we do not auto-charge here + if sub.provider != "yookassa": + logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider) return True from db.dal.user_billing_dal import get_user_default_payment_method diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py deleted file mode 100644 index 0e48ce7..0000000 --- a/bot/services/tribute_service.py +++ /dev/null @@ -1,335 +0,0 @@ -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 .notification_service import NotificationService -from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard -from db.dal import payment_dal, user_dal, subscription_dal -from bot.utils.text_sanitizer import sanitize_display_name, username_for_display - - -def convert_period_to_months(period: Optional[str]) -> int: - """Map Tribute subscription period strings to months.""" - if not period: - return 1 - - mapping = { - "monthly": 1, - "quarterly": 3, - "3-month": 3, - "3months": 3, - "3-months": 3, - "q": 3, - "halfyearly": 6, - "yearly": 12, - "annual": 12, - "y": 12, - } - return mapping.get(period.lower(), 1) - - -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 - - def ok(data: Optional[dict] = None) -> web.Response: - payload = {"status": "ok"} - if data: - payload.update(data) - return web.json_response(payload, status=200) - - def ignored(reason: str) -> web.Response: - return web.json_response({"status": "ignored", "reason": reason}, status=200) - - def bad_request(reason: str) -> web.Response: - return web.json_response({"status": "error", "reason": reason}, status=400) - - if settings.TRIBUTE_API_KEY: - if not signature_header: - return web.json_response({"status": "error", "reason": "no_signature"}, status=403) - 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.json_response({"status": "error", "reason": "invalid_signature"}, status=403) - - try: - payload = json.loads(raw_body.decode()) - except Exception: - return bad_request("invalid_json") - - logging.info( - "Tribute webhook data: %s", - json.dumps(payload, ensure_ascii=False), - ) - - # Tribute webhook spec: only two events are sent - # name: new_subscription | cancelled_subscription - event_name = payload.get("name") - data = payload.get("payload", {}) - - # Mandatory routing fields - user_id = data.get("telegram_user_id") - if not user_id: - # Permanent format issue — acknowledge to avoid retries - return ignored("missing_telegram_user_id") - - period_val = data.get("period") - months = convert_period_to_months(period_val) - - # Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting. - amount_value = data.get("amount") or data.get("price") - currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - if amount_value is not None: - try: - amount_minor_units = float(amount_value) - except (TypeError, ValueError): - amount_minor_units = 0.0 - amount_float = round(amount_minor_units / 100.0, 2) - else: - amount_float = 0.0 - - async with async_session_factory() as session: - if event_name == "new_subscription": - # Use a unique, idempotent provider payment id per webhook event - # Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix - candidate_event_id = ( - str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "") - ) - if candidate_event_id: - provider_payment_id = candidate_event_id - else: - # Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event - sub_id_part = str(data.get("subscription_id") or "sub") - payload_hash = hashlib.sha256(raw_body).hexdigest()[:16] - provider_payment_id = f"{sub_id_part}:{payload_hash}" - - # Idempotent ensure payment - payment_record = await payment_dal.ensure_payment_with_provider_id( - session, - user_id=int(user_id), - amount=amount_float, - currency=currency, - months=months, - description="Tribute subscription", - provider="tribute", - provider_payment_id=provider_payment_id, - ) - - activation_details = await subscription_service.activate_subscription( - session, - int(user_id), - months, - float(amount_float), - payment_record.payment_id, - provider="tribute", - ) - referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, - int(user_id), - months, - current_payment_db_id=payment_record.payment_id, - skip_if_active_before_payment=False, - ) - await session.commit() - - db_user = await user_dal.get_user_by_id(session, int(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: - config_link = activation_details.get("subscription_url") or _( - "config_link_not_available" - ) - - 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: - safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None - if safe_name: - inviter_name_display = safe_name - elif inviter.username: - inviter_name_display = username_for_display(inviter.username, with_at=False) - success_msg = _( - "payment_successful_with_referral_bonus_full", - 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, - config_link=config_link, - ) - else: - success_msg = _( - "payment_successful_full", - months=months, - end_date=final_end.strftime('%Y-%m-%d'), - config_link=config_link, - ) - markup = get_connect_and_main_keyboard( - lang, - i18n, - settings, - config_link, - preserve_message=True, - ) - - try: - # Use user's DB language in success messages prepared above - await bot.send_message( - int(user_id), - success_msg, - reply_markup=markup, - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception as e: - logging.error( - f"Failed to send Tribute payment success message to user {user_id}: {e}") - - # Send notification about payment - try: - notification_service = NotificationService(bot, settings, i18n) - user = await user_dal.get_user_by_id(session, int(user_id)) - await notification_service.notify_payment_received( - user_id=int(user_id), - amount=float(amount_float), - currency=currency, - months=months, - payment_provider="tribute", - username=user.username if user else None - ) - except Exception as e: - logging.error(f"Failed to send tribute payment notification: {e}") - elif event_name == "cancelled_subscription": - await self._handle_tribute_cancellation(session, int(user_id), bot, i18n) - - else: - await session.commit() - # Acknowledge to Tribute that webhook was received and processed/accepted - return ok({"event": event_name or "unknown"}) - - async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n): - """Handle tribute subscription cancellation - set subscription to 1 day grace period""" - from datetime import datetime, timezone, timedelta - from db.dal import subscription_dal, user_dal - from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup - - try: - grace_days = 1 - grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days) - - active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id) - - panel_users_updated: set[str] = set() - for sub in active_subscriptions: - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "end_date": grace_end, - "status_from_panel": "CANCELLED", - "skip_notifications": True, - }, - ) - - panel_uuid = updated_sub.panel_user_uuid if updated_sub else None - if panel_uuid and panel_uuid not in panel_users_updated: - panel_users_updated.add(panel_uuid) - panel_payload = { - "expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"), - } - try: - await self.panel_service.update_user_details_on_panel( - panel_uuid, - panel_payload, - log_response=False, - ) - except Exception as panel_err: - logging.error( - f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}") - - await session.commit() - - # Send notification about cancellation if enabled - if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: - 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 self.settings.DEFAULT_LANGUAGE - first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" - - _ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k - markup = get_subscribe_only_markup(lang, i18n) - - cancellation_msg = _( - "tribute_subscription_cancelled", - default="🚨 Подписка отменена\n\n" - "Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, " - "после чего подписка будет заблокирована.\n\n" - "Для продления подписки нажмите кнопку ниже.", - user_name=first_name - ) - - try: - await bot.send_message( - int(user_id), - cancellation_msg, - reply_markup=markup, - parse_mode="HTML" - ) - except Exception as e: - logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}") - - logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day") - - except Exception as e: - logging.error(f"Error handling tribute cancellation for user {user_id}: {e}") - await session.rollback() - - -async def tribute_webhook_route(request: web.Request): - """AIOHTTP route handler for Tribute webhook calls.""" - tribute_service: TributeService = request.app['tribute_service'] - raw_body = await request.read() - signature_header = request.headers.get('trbt-signature') - return await tribute_service.handle_webhook(raw_body, signature_header) diff --git a/config/settings.py b/config/settings.py index 5700d3c..783dca4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -84,10 +84,9 @@ class Settings(BaseSettings): YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) - TRIBUTE_ENABLED: bool = Field(default=True) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay,tribute)", + description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") @@ -104,15 +103,6 @@ class Settings(BaseSettings): 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) - TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments") - TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments") PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) @@ -265,21 +255,6 @@ class Settings(BaseSettings): return f"{base.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]: - base = self.WEBHOOK_BASE_URL - if base: - return f"{base.rstrip('/')}{self.tribute_webhook_path}" - return None - - @computed_field - @property def panel_webhook_path(self) -> str: return "/webhook/panel" @@ -385,22 +360,6 @@ class Settings(BaseSettings): 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_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH: - links[1] = self.TRIBUTE_LINK_1_MONTH - 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_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS: - links[6] = self.TRIBUTE_LINK_6_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 - - @computed_field - @property def referral_bonus_inviter(self) -> Dict[int, int]: bonuses: Dict[int, int] = {} if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None: @@ -444,7 +403,6 @@ class Settings(BaseSettings): "platega", "severpay", "yookassa", - "tribute", "stars", "cryptopay", ] diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index cfd63de..87150ec 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -239,31 +239,6 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: } -async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]: - """Get duration in months from the last successful tribute payment for a user.""" - stmt = select(Payment.subscription_duration_months).where( - and_( - Payment.user_id == user_id, - Payment.provider == 'tribute', - Payment.status == 'succeeded' - ) - ).order_by(Payment.created_at.desc()).limit(1) - - result = await session.execute(stmt) - return result.scalar_one_or_none() - - -async def get_last_tribute_payment( - session: AsyncSession, user_id: int) -> Optional[Payment]: - """Return the most recent succeeded Tribute payment for the user.""" - stmt = (select(Payment).where( - and_(Payment.user_id == user_id, Payment.provider == 'tribute', - Payment.status == 'succeeded')).order_by( - Payment.created_at.desc()).limit(1)) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - async def get_user_total_paid(session: AsyncSession, user_id: int) -> float: """Get total amount paid by a specific user (sum of all succeeded payments).""" stmt = select(func.sum(Payment.amount)).where( @@ -295,4 +270,4 @@ async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float ) result = await session.execute(stmt) total = result.scalar() - return float(total or 0) \ No newline at end of file + return float(total or 0) diff --git a/locales/en.json b/locales/en.json index fe93a59..1176664 100644 --- a/locales/en.json +++ b/locales/en.json @@ -41,7 +41,6 @@ "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Back", "pay_with_cryptopay_button": "💎 CryptoBot", - "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Telegram Stars", "connect_button": "🔗 Connect", "cancel_button": "❌ Cancel", @@ -240,8 +239,6 @@ "autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.", "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", - "tribute_subscription_cancelled": "🚨 Subscription Cancelled\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.", - "tribute_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", "yookassa_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}", "admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:", "admin_user_subscription_info": "Subscription Information:", @@ -442,9 +439,6 @@ "payment_method_tx_history_title": "📜 Transactions history", "payment_method_no_history": "No transactions history.", "subscription_purchase_title": "Subscription purchase for {months} mo.", - "subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.", - "subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}", - "subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.", "autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index b8bd4b7..b3f669a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -41,7 +41,6 @@ "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", - "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Звезды Telegram", "connect_button": "🔗 Подключиться", @@ -169,7 +168,6 @@ "autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.", "autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.", "autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.", - "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_promo_set_validity_days": "⏰ Установить срок (дни)", "admin_back_to_panel": "⬅️ В панель", @@ -242,7 +240,6 @@ "admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.", "error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.", "error_displaying_statistics": "Ошибка отображения статистики.", - "tribute_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:", "admin_user_subscription_info": "Информация о подписке:", "admin_user_reset_trial_button": "🔄 Сбросить триал", @@ -442,9 +439,6 @@ "payment_method_tx_history_title": "📜 История операций", "payment_method_no_history": "История операций отсутствует.", "subscription_purchase_title": "Покупка подписки на {months} мес.", - "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", - "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", - "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", "autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.",