diff --git a/.env.example b/.env.example index 2ed40f7..8afef7d 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,9 @@ STARS_ENABLED=True # TRIBUTE_ENABLED=True # Turn on TRIBUTE CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY PLATEGA_ENABLED=False # Turn on PLATEGA +SEVERPAY_ENABLED=False # Turn on SeverPay +# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay +PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa @@ -61,12 +64,19 @@ CRYPTOPAY_CURRENCY_TYPE=fiat # CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT # Platega Payment Gateway Configuration -PLATEGA_BASE_URL=https://app.platega.io # Base API URL -PLATEGA_MERCHANT_ID= # Your MerchantId from Platega -PLATEGA_SECRET= # API secret from Platega -PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto) -PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link) -PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL) +PLATEGA_BASE_URL=https://app.platega.io # Base API URL +PLATEGA_MERCHANT_ID= # Your MerchantId from Platega +PLATEGA_SECRET= # API secret from Platega +PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto) +PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link) +PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL) + +# SeverPay Payment Gateway Configuration +SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL +SEVERPAY_MID= # Your MID from SeverPay +SEVERPAY_TOKEN= # API token/secret for signing requests +SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) +SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default) # Tribute Payment Gateway Configuration TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures @@ -95,14 +105,14 @@ STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= # Subscription Notifications -SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription -SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription -SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after -SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify +SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription +SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription +SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after +SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify -REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee -LEGACY_REFS=true # Allow ref_ links. Leave unset/true unless you want to disable old links +REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee +LEGACY_REFS=true # Allow ref_ links. Leave unset/true unless you want to disable old links # Referral Bonus Days # Bonus for the inviting user REFERRAL_BONUS_DAYS_1_MONTH=3 @@ -116,41 +126,41 @@ REFEREE_BONUS_DAYS_6_MONTHS=7 REFEREE_BONUS_DAYS_12_MONTHS=15 # Panel API Configuration -PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API -PANEL_API_KEY=your_panel_api_key # Panel API key -PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures +PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API +PANEL_API_KEY=your_panel_api_key # Panel API key +PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures # User traffic limits (applied for all users) # 0 means unlimited -USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited) -USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH) +USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited) +USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH) # Default Internal Squads for Users (Optional, comma-separated UUIDs) USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 # Default External Squad for Users (Optional, single UUID) -USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users +USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users # Trial Settings -TRIAL_ENABLED=True # Enable the trial period -TRIAL_DURATION_DAYS=5 # Duration of the trial period in days -TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited) -TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH) +TRIAL_ENABLED=True # Enable the trial period +TRIAL_DURATION_DAYS=5 # Duration of the trial period in days +TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited) +TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH) # Web Server Settings (for handling webhooks) WEB_SERVER_HOST="0.0.0.0" WEB_SERVER_PORT=8080 # Admin Panel Log Pagination -LOGS_PAGE_SIZE=10 # Number of events in the log +LOGS_PAGE_SIZE=10 # Number of events in the log # Admin Logging Configuration -LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications -LOG_THREAD_ID= # Optional: Thread ID for supergroup messages -LOG_NEW_USERS=True # Log new user registrations -LOG_PAYMENTS=True # Log payments -LOG_PROMO_ACTIVATIONS=True # Log promo code activations -LOG_TRIAL_ACTIVATIONS=True # Log trial activations -LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity +LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications +LOG_THREAD_ID= # Optional: Thread ID for supergroup messages +LOG_NEW_USERS=True # Log new user registrations +LOG_PAYMENTS=True # Log payments +LOG_PROMO_ACTIVATIONS=True # Log promo code activations +LOG_TRIAL_ACTIVATIONS=True # Log trial activations +LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity # Embedded mode thumbnails. Please don't touch this if you don't know what it is. INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png diff --git a/README.md b/README.md index 1b9bd6f..d9e937d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, CryptoPay, Telegram Stars и Tribute. + - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -28,7 +28,7 @@ - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. -- **YooKassa, FreeKassa API, Platega, aiocryptopay:** Интеграции с платежными системами. +- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами. - **Pydantic:** Для управления настройками из `.env` файла. - **Docker & Docker Compose:** Для контейнеризации и развертывания. @@ -77,14 +77,15 @@ | Переменная | Описание | | --- | --- | - | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | - | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | - | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | - | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | - | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | - | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | - | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | - | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | +| `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`. Первый будет сверху. | +| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | +| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | +| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | +| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | +| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | @@ -102,6 +103,12 @@ | `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | | `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | | `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | +| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). | +| `SEVERPAY_MID` | MID магазина в SeverPay. | +| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. | +| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. | +| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). | +| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
@@ -156,6 +163,7 @@ - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa` - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:/webhook/freekassa` - `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` diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index f3a896b..394bb0f 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -14,6 +14,7 @@ from bot.services.crypto_pay_service import CryptoPayService from bot.services.panel_webhook_service import PanelWebhookService from bot.services.freekassa_service import FreeKassaService from bot.services.platega_service import PlategaService +from bot.services.severpay_service import SeverPayService def build_core_services( @@ -64,6 +65,15 @@ def build_core_services( referral_service=referral_service, default_return_url=bot_username_for_default_return, ) + severpay_service = SeverPayService( + bot=bot, + settings=settings, + i18n=i18n, + async_session_factory=async_session_factory, + subscription_service=subscription_service, + referral_service=referral_service, + default_return_url=bot_username_for_default_return, + ) panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service) yookassa_service = YooKassaService( shop_id=settings.YOOKASSA_SHOP_ID, @@ -94,4 +104,5 @@ def build_core_services( "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, "platega_service": platega_service, + "severpay_service": severpay_service, } diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index c0a5631..c9745e9 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -32,6 +32,7 @@ async def build_and_start_web_app( "tribute_service", "panel_webhook_service", "platega_service", + "severpay_service", ): # Access dispatcher workflow_data directly to avoid sequence protocol issues if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore @@ -54,6 +55,7 @@ async def build_and_start_web_app( 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("/"): @@ -75,6 +77,11 @@ async def build_and_start_web_app( app.router.add_post(pg_path, platega_webhook_route) logging.info(f"Platega webhook route configured at: [POST] {pg_path}") + sp_path = settings.severpay_webhook_path + if sp_path.startswith("/"): + app.router.add_post(sp_path, severpay_webhook_route) + logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}") + # YooKassa webhook (register only when base URL present and path configured) yk_path = settings.yookassa_webhook_path if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"): diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 19aad73..36fb4e7 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -38,8 +38,16 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: """Format single payment info as text.""" _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) + pending_statuses = [ + 'pending', + 'pending_yookassa', + 'pending_freekassa', + 'pending_platega', + 'pending_severpay', + 'pending_cryptopay', + ] status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌" + "⏳" if payment.status in pending_statuses else "❌" ) user_info = f"User {payment.user_id}" @@ -56,6 +64,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: 'telegram_stars': 'Telegram Stars', 'cryptopay': 'CryptoPay', 'freekassa': 'FreeKassa', + 'severpay': 'SeverPay', + 'platega': 'Platega', }.get(payment.provider, payment.provider or 'Unknown') return ( diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 726b462..ac58d30 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -162,8 +162,21 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts.append( f"\n{_('admin_stats_recent_payments_header')}") for payment in last_payments_models: - status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌") + pending_statuses = [ + "pending", + "pending_yookassa", + "pending_freekassa", + "pending_platega", + "pending_severpay", + "pending_cryptopay", + ] + status_emoji = ( + "✅" + if payment.status == "succeeded" + else "⏳" + if payment.status in pending_statuses + else "❌" + ) user_info = f"User {payment.user_id}" if payment.user and payment.user.username: diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 639c5b7..22b1f4f 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -19,6 +19,7 @@ from bot.services.freekassa_service import FreeKassaService from bot.services.crypto_pay_service import CryptoPayService from bot.services.stars_service import StarsService from bot.services.platega_service import PlategaService +from bot.services.severpay_service import SeverPayService from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal, user_billing_dal @@ -1173,6 +1174,178 @@ async def pay_platega_callback_handler( pass +@router.callback_query(F.data.startswith("pay_severpay:")) +async def pay_severpay_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + severpay_service: SeverPayService, + session: AsyncSession, +): + 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: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + if not severpay_service or not severpay_service.configured: + logging.error("SeverPay service is not configured or unavailable.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + months_str, price_str = data_payload.split(":") + months = int(months_str) + price_rub = float(price_str) + except (ValueError, IndexError): + logging.error(f"Invalid pay_severpay data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + payment_description = get_text("payment_description_subscription", months=months) + currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_severpay", + "description": payment_description, + "subscription_duration_months": months, + "provider": "severpay", + } + + try: + payment_record = await payment_dal.create_payment_record(session, payment_record_payload) + await session.commit() + except Exception as e_db_create: + await session.rollback() + logging.error( + f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + success, response_data = await severpay_service.create_payment( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + ) + + if success: + payment_link = ( + response_data.get("url") + or response_data.get("payment_url") + or response_data.get("paymentUrl") + ) + provider_identifier = response_data.get("id") or response_data.get("uid") + + if provider_identifier: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(provider_identifier), + payment_record.status, + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + if payment_link: + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_link, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.") + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_link, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "SeverPay: payment created but missing payment link for payment %s. Response: %s", + payment_record.payment_id, + response_data, + ) + + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_record.payment_id, + "failed_creation", + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) + + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + + @router.callback_query(F.data.startswith("pay_crypto:")) async def pay_crypto_callback_handler( callback: types.CallbackQuery, diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 3f18d3b..333464d 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -118,23 +118,39 @@ def get_payment_method_keyboard(months: int, price: float, i18n_instance, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - if settings.FREEKASSA_ENABLED: - builder.button(text=_("pay_with_sbp_button"), - callback_data=f"pay_fk:{months}:{price}") - if settings.PLATEGA_ENABLED: - builder.button(text=_("pay_with_platega_button"), - callback_data=f"pay_platega:{months}:{price}") - if settings.YOOKASSA_ENABLED: - builder.button(text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{months}:{price}") - if settings.TRIBUTE_ENABLED and tribute_url: - builder.button(text=_("pay_with_tribute_button"), url=tribute_url) - 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 settings.CRYPTOPAY_ENABLED: - builder.button(text=_("pay_with_cryptopay_button"), - callback_data=f"pay_crypto:{months}:{price}") + for method in settings.payment_methods_order: + if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False): + builder.button( + text=_("pay_with_severpay_button"), + callback_data=f"pay_severpay:{months}:{price}", + ) + elif method == "freekassa" and settings.FREEKASSA_ENABLED: + builder.button( + text=_("pay_with_sbp_button"), + callback_data=f"pay_fk:{months}:{price}", + ) + elif method == "platega" and settings.PLATEGA_ENABLED: + builder.button( + text=_("pay_with_platega_button"), + callback_data=f"pay_platega:{months}:{price}", + ) + elif method == "yookassa" and settings.YOOKASSA_ENABLED: + builder.button( + 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"), + callback_data=f"pay_stars:{months}:{stars_price}", + ) + elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED: + builder.button( + text=_("pay_with_cryptopay_button"), + callback_data=f"pay_crypto:{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 5850061..a91b55b 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -208,6 +208,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "subscription_service", "referral_service", "platega_service", + "severpay_service", ): await close_service(service_key) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 6cf1c88..a4ef9bf 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -242,6 +242,7 @@ class NotificationService: "stars": "⭐", "tribute": "💎", "platega": "💳", + "severpay": "💳", }.get(payment_provider.lower(), "💰") message = _( diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py new file mode 100644 index 0000000..a49aff7 --- /dev/null +++ b/bot/services/severpay_service.py @@ -0,0 +1,354 @@ +import json +import logging +import secrets +import hmac +import hashlib +from decimal import Decimal, ROUND_HALF_UP +from typing import Optional, Dict, Any, Tuple + +from aiohttp import ClientSession, ClientTimeout, 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.referral_service import ReferralService +from bot.services.notification_service import NotificationService +from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from db.dal import payment_dal, user_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display + + +class SeverPayService: + def __init__( + self, + *, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + default_return_url: str, + ): + self.bot = bot + self.settings = settings + self.i18n = i18n + self.async_session_factory = async_session_factory + self.subscription_service = subscription_service + self.referral_service = referral_service + + self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/") + self.mid = settings.SEVERPAY_MID + self.token = settings.SEVERPAY_TOKEN or "" + self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}" + self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES + + self._timeout = ClientTimeout(total=15) + self._session: Optional[ClientSession] = None + + self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token) + if not self.configured: + logging.warning("SeverPayService initialized but not fully configured. Payments disabled.") + + async def _get_session(self) -> ClientSession: + if self._session is None or self._session.closed: + self._session = ClientSession(timeout=self._timeout) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + @staticmethod + def _format_amount(amount: float) -> str: + quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{quantized:.2f}" + + def _sign_payload(self, payload: Dict[str, Any]) -> str: + message = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + + def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]: + body: Dict[str, Any] = { + "mid": self.mid, + "salt": secrets.token_hex(8), + } + body.update(extra) + sorted_body = dict(sorted(body.items())) + sorted_body["sign"] = self._sign_payload(sorted_body) + return sorted_body + + def _validate_signature(self, payload: Dict[str, Any]) -> bool: + provided_sign = str(payload.get("sign") or "") + if not provided_sign or not self.token: + return False + data = {k: v for k, v in payload.items() if k != "sign"} + sorted_body = dict(sorted(data.items())) + expected_sign = self._sign_payload(sorted_body) + return hmac.compare_digest(provided_sign, expected_sign) + + async def create_payment( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + currency: Optional[str], + description: str, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("SeverPayService is not configured. Cannot create payment.") + return False, {"message": "service_not_configured"} + + session = await self._get_session() + url = f"{self.base_url}/payin/create" + currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + amount_str = self._format_amount(amount) + + body = { + "order_id": str(payment_db_id), + "amount": amount_str, + "currency": currency_code, + "client_email": f"{user_id}@telegram.org", + "client_id": str(user_id), + "url_return": self.return_url, + } + + if self.lifetime_minutes: + body["lifetime"] = int(self.lifetime_minutes) + + signed_body = self._build_signed_body(body) + + try: + async with session.post(url, json=signed_body) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("SeverPay create_payment: invalid JSON response: %s", response_text) + return False, {"status": response.status, "message": "invalid_json", "raw": response_text} + + if response.status != 200 or not response_data.get("status"): + logging.error( + "SeverPay create_payment: API returned error (status=%s, body=%s)", + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + + return True, response_data.get("data") or response_data + except Exception as exc: + logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True) + return False, {"message": str(exc)} + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) + + try: + payload = await request.json() + except Exception as exc: + logging.error("SeverPay webhook: failed to parse JSON: %s", exc) + return web.json_response({"status": False, "msg": "bad_request"}, status=400) + + if not isinstance(payload, dict) or not self._validate_signature(payload): + logging.error("SeverPay webhook: invalid signature or payload.") + return web.json_response({"status": False, "msg": "invalid_signature"}, status=403) + + event_type = str(payload.get("type") or "").lower() + data = payload.get("data") or {} + + if event_type != "payin" or not isinstance(data, dict): + logging.warning("SeverPay webhook: unsupported event type '%s'", event_type) + return web.json_response({"status": True}) + + provider_payment_id = str(data.get("id") or data.get("uid") or "") + order_id_raw = data.get("order_id") + status = str(data.get("status") or "").lower() + + payment_db_id: Optional[int] = None + try: + if isinstance(order_id_raw, int): + payment_db_id = order_id_raw + elif isinstance(order_id_raw, str) and order_id_raw.isdigit(): + payment_db_id = int(order_id_raw) + except Exception: + payment_db_id = None + + async with self.async_session_factory() as session: + payment = None + if payment_db_id is not None: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment and provider_payment_id: + payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id) + + if not payment: + logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id) + return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) + + payment_months = payment.subscription_duration_months or 1 + if status == "success": + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + provider_payment_id or str(payment.payment_id), + "succeeded", + ) + + activation = await self.subscription_service.activate_subscription( + session, + payment.user_id, + payment_months, + float(payment.amount), + payment.payment_id, + provider="severpay", + ) + + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + payment_months, + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) + + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True) + return web.json_response({"status": False, "msg": "processing_error"}, status=500) + + db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k + + config_link = ( + activation.get("subscription_url") + if activation + else None + ) or _("config_link_not_available") + final_end = activation.get("end_date") if activation else None + applied_days = 0 + applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0 + + if referral_bonus and referral_bonus.get("referee_new_end_date"): + final_end = referral_bonus["referee_new_end_date"] + applied_days = referral_bonus.get("referee_bonus_applied_days", 0) + + if applied_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) + + text = _( + "payment_successful_with_referral_bonus_full", + months=payment_months, + base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "", + bonus_days=applied_days, + final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + inviter_name=inviter_name_display, + config_link=config_link, + ) + elif applied_promo_days and final_end: + text = _( + "payment_successful_with_promo_full", + months=payment_months, + bonus_days=applied_promo_days, + end_date=final_end.strftime("%Y-%m-%d"), + config_link=config_link, + ) + else: + text = _( + "payment_successful_full", + months=payment_months, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + + markup = get_connect_and_main_keyboard( + lang, + self.i18n, + self.settings, + config_link, + preserve_message=True, + ) + try: + await self.bot.send_message( + payment.user_id, + text, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc) + + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + await notification_service.notify_payment_received( + user_id=payment.user_id, + amount=float(payment.amount), + currency=payment.currency, + months=payment_months, + payment_provider="severpay", + username=db_user.username if db_user else None, + ) + except Exception as exc: + logging.error("SeverPay webhook: failed to notify admins: %s", exc) + + return web.json_response({"status": True}) + + if status in {"fail", "decline"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + provider_payment_id or str(payment.payment_id), + "failed", + ) + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc) + return web.json_response({"status": False, "msg": "processing_error"}, status=500) + + db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k + try: + await self.bot.send_message(payment.user_id, _("payment_failed")) + except Exception: + pass + return web.json_response({"status": True}) + + if status in {"process", "new"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + provider_payment_id or str(payment.payment_id), + "pending_severpay", + ) + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc) + return web.json_response({"status": True}) + + logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id) + return web.json_response({"status": True}) + + +async def severpay_webhook_route(request: web.Request) -> web.Response: + service: SeverPayService = request.app["severpay_service"] + return await service.webhook_route(request) diff --git a/config/settings.py b/config/settings.py index 1c81e88..319fcbe 100644 --- a/config/settings.py +++ b/config/settings.py @@ -72,9 +72,23 @@ class Settings(BaseSettings): FREEKASSA_PAYMENT_IP: Optional[str] = None FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None + SEVERPAY_ENABLED: bool = Field(default=False) + SEVERPAY_MID: Optional[int] = None + SEVERPAY_TOKEN: Optional[str] = None + SEVERPAY_RETURN_URL: Optional[str] = None + SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant") + SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field( + default=None, + description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)", + ) + 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)", + ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") @@ -303,6 +317,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.freekassa_webhook_path}" return None + @computed_field + @property + def severpay_webhook_path(self) -> str: + return "/webhook/severpay" + + @computed_field + @property + def severpay_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.severpay_webhook_path}" + return None + @computed_field @property def platega_webhook_path(self) -> str: @@ -399,6 +426,30 @@ class Settings(BaseSettings): if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None: bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS return bonuses + + @computed_field + @property + def payment_methods_order(self) -> List[str]: + """ + Ordered list of payment providers to show in the subscription payment keyboard. + """ + default_order = [ + "freekassa", + "platega", + "severpay", + "yookassa", + "tribute", + "stars", + "cryptopay", + ] + if not self.PAYMENT_METHODS_ORDER: + return default_order + methods = [] + for item in self.PAYMENT_METHODS_ORDER.split(","): + slug = item.strip().lower() + if slug: + methods.append(slug) + return methods or default_order # Logging Configuration LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") @@ -416,6 +467,7 @@ class Settings(BaseSettings): 'REQUIRED_CHANNEL_LINK', 'PLATEGA_RETURN_URL', 'PLATEGA_FAILED_URL', + 'SEVERPAY_RETURN_URL', mode='before', ) @classmethod @@ -424,7 +476,7 @@ class Settings(BaseSettings): return None return v - @field_validator('USER_HWID_DEVICE_LIMIT', mode='before') + @field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before') @classmethod def validate_optional_int(cls, v): if isinstance(v, str): @@ -492,6 +544,11 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." ) + if _settings_instance.SEVERPAY_ENABLED: + if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN: + logging.warning( + "CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." + ) except ValidationError as e: logging.critical( diff --git a/locales/en.json b/locales/en.json index 77c7749..fe93a59 100644 --- a/locales/en.json +++ b/locales/en.json @@ -38,6 +38,7 @@ "yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.", "pay_with_sbp_button": "📱 SBP", "pay_with_platega_button": "💳 Platega (SBP/Cards)", + "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Back", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", diff --git a/locales/ru.json b/locales/ru.json index eb9d86a..b8bd4b7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -38,6 +38,7 @@ "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", "pay_with_sbp_button": "📱 СБП", "pay_with_platega_button": "💳 Platega (СБП/карты)", + "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute",