From fb06fbd0e16b208f6ae06b3ab3feee7e38515aeb Mon Sep 17 00:00:00 2001 From: machka pasla Date: Sun, 7 Dec 2025 20:24:24 +0300 Subject: [PATCH] added platega.io --- .env.example | 9 + README.md | 35 ++- bot/app/factories/build_services.py | 12 +- bot/app/web/web_server.py | 8 +- bot/handlers/user/subscription/payments.py | 183 ++++++++++++ bot/keyboards/inline/user_keyboards.py | 3 + bot/main_bot.py | 1 + bot/services/notification_service.py | 3 +- bot/services/platega_service.py | 322 +++++++++++++++++++++ config/settings.py | 38 ++- locales/en.json | 1 + locales/ru.json | 1 + 12 files changed, 598 insertions(+), 18 deletions(-) create mode 100644 bot/services/platega_service.py diff --git a/.env.example b/.env.example index 7538c8b..2ed40f7 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ FREEKASSA_ENABLED=True # STARS_ENABLED=True # Turn on STARS TRIBUTE_ENABLED=True # Turn on TRIBUTE CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY +PLATEGA_ENABLED=False # Turn on PLATEGA # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa @@ -59,6 +60,14 @@ CRYPTOPAY_NETWORK=mainnet # CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto) 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) + # Tribute Payment Gateway Configuration TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments diff --git a/README.md b/README.md index 5d62ad4..1b9bd6f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute. +- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -28,7 +28,7 @@ - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. -- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами. +- **YooKassa, FreeKassa API, Platega, aiocryptopay:** Интеграции с платежными системами. - **Pydantic:** Для управления настройками из `.env` файла. - **Docker & Docker Compose:** Для контейнеризации и развертывания. @@ -85,18 +85,24 @@ | `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`). | - | `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. | - | `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. | - | `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. | - | `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. | - | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | - | `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | - | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | - | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | - +| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | +| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | +| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | +| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. | +| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. | +| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. | +| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. | +| `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. | +| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | +| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | +| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | +
Настройки подписок @@ -149,6 +155,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/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 b261445..f3a896b 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -13,6 +13,7 @@ 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 +from bot.services.platega_service import PlategaService def build_core_services( @@ -54,6 +55,15 @@ def build_core_services( subscription_service, referral_service, ) + platega_service = PlategaService( + 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, @@ -83,5 +93,5 @@ def build_core_services( "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 0604ff4..c0a5631 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -31,6 +31,7 @@ async def build_and_start_web_app( "cryptopay_service", "tribute_service", "panel_webhook_service", + "platega_service", ): # Access dispatcher workflow_data directly to avoid sequence protocol issues if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore @@ -52,6 +53,7 @@ async def build_and_start_web_app( 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 tribute_path = settings.tribute_webhook_path if tribute_path.startswith("/"): @@ -68,6 +70,11 @@ async def build_and_start_web_app( app.router.add_post(fk_path, freekassa_webhook_route) logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}") + pg_path = settings.platega_webhook_path + if pg_path.startswith("/"): + app.router.add_post(pg_path, platega_webhook_route) + logging.info(f"Platega webhook route configured at: [POST] {pg_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("/"): @@ -94,4 +101,3 @@ async def build_and_start_web_app( # Run until cancelled await asyncio.Event().wait() - diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 5157552..639c5b7 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,3 +1,4 @@ +import json import logging from datetime import datetime from aiogram import Router, F, types @@ -17,6 +18,7 @@ from bot.services.yookassa_service import YooKassaService 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.middlewares.i18n import JsonI18n from db.dal import payment_dal, user_billing_dal @@ -990,6 +992,187 @@ async def pay_fk_callback_handler( pass +@router.callback_query(F.data.startswith("pay_platega:")) +async def pay_platega_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + platega_service: PlategaService, + 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 platega_service or not platega_service.configured: + logging.error("Platega 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_platega 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_platega", + "description": payment_description, + "subscription_duration_months": months, + "provider": "platega", + } + + 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"Platega: 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 + + payload_meta = json.dumps( + { + "payment_db_id": payment_record.payment_id, + "user_id": user_id, + "months": months, + } + ) + + success, response_data = await platega_service.create_transaction( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + payload=payload_meta, + ) + + if success: + transaction_id = response_data.get("transactionId") or response_data.get("id") + redirect_url = ( + response_data.get("redirect") + or response_data.get("url") + or response_data.get("paymentUrl") + ) + provider_status = response_data.get("status", payment_record.status) + + if transaction_id and redirect_url: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(transaction_id), + str(provider_status), + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + redirect_url, + 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"Platega: 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( + redirect_url, + 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( + "Platega: transaction created but missing transaction id or 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"Platega: 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 96a43a2..3f18d3b 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -121,6 +121,9 @@ def get_payment_method_keyboard(months: int, price: float, 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}") diff --git a/bot/main_bot.py b/bot/main_bot.py index 0a7fa10..5850061 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -207,6 +207,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "stars_service", "subscription_service", "referral_service", + "platega_service", ): await close_service(service_key) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index f2d6db5..6cf1c88 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -240,7 +240,8 @@ class NotificationService: "freekassa": "💳", "cryptopay": "₿", "stars": "⭐", - "tribute": "💎" + "tribute": "💎", + "platega": "💳", }.get(payment_provider.lower(), "💰") message = _( diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py new file mode 100644 index 0000000..c449034 --- /dev/null +++ b/bot/services/platega_service.py @@ -0,0 +1,322 @@ +import json +import logging +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.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from bot.services.notification_service import NotificationService +from db.dal import payment_dal, user_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display + + +class PlategaService: + 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.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/") + self.merchant_id = settings.PLATEGA_MERCHANT_ID + self.secret = settings.PLATEGA_SECRET + self.payment_method = settings.PLATEGA_PAYMENT_METHOD + self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}" + self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url + + self._timeout = ClientTimeout(total=20) + self._session: Optional[ClientSession] = None + self._auth_headers = { + "X-MerchantId": self.merchant_id or "", + "X-Secret": self.secret or "", + "Content-Type": "application/json", + } + self.configured: bool = bool( + settings.PLATEGA_ENABLED and self.merchant_id and self.secret + ) + if not self.configured: + logging.warning("PlategaService 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() + + async def create_transaction( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + currency: Optional[str], + description: str, + payload: Optional[str] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("PlategaService is not configured. Cannot create transaction.") + return False, {"message": "service_not_configured"} + + session = await self._get_session() + url = f"{self.base_url}/transaction/process" + currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + + body: Dict[str, Any] = { + "paymentMethod": int(self.payment_method), + "paymentDetails": {"amount": float(amount), "currency": currency_code}, + "description": description, + "return": self.return_url, + "failedUrl": self.failed_url, + "payload": payload, + } + + # Remove optional keys with falsy values to avoid validation errors + clean_body = {k: v for k, v in body.items() if v not in (None, "")} + + try: + async with session.post(url, json=clean_body, headers=self._auth_headers) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("Platega create_transaction: invalid JSON response: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + + if response.status != 200: + logging.error( + "Platega create_transaction: API returned error (status=%s, body=%s)", + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + + return True, response_data + except Exception as exc: + logging.error("Platega create_transaction: 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.Response(status=503, text="platega_disabled") + + try: + data = await request.json() + except Exception as exc: + logging.error("Platega webhook: failed to parse JSON: %s", exc) + return web.Response(status=400, text="bad_request") + + header_merchant = request.headers.get("X-MerchantId") + header_secret = request.headers.get("X-Secret") + if header_merchant != self.merchant_id or header_secret != self.secret: + logging.error("Platega webhook: invalid auth headers") + return web.Response(status=403, text="forbidden") + + transaction_id = str(data.get("id") or data.get("transactionId") or "").strip() + status = str(data.get("status") or "").upper() + amount_raw = data.get("amount") + currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + if not transaction_id or not status: + logging.error("Platega webhook: missing transaction id or status in payload: %s", data) + return web.Response(status=400, text="missing_fields") + + async with self.async_session_factory() as session: + payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id) + if not payment: + logging.error("Platega webhook: payment not found for transaction %s", transaction_id) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded" and status == "CONFIRMED": + return web.Response(text="ok") + + payment_months = payment.subscription_duration_months or 1 + + if status == "CONFIRMED": + if amount_raw is not None: + try: + incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if incoming_amount != expected_amount: + logging.warning( + "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", + payment.payment_id, + expected_amount, + incoming_amount, + ) + except Exception as exc: + logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc) + + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + transaction_id, + "succeeded", + ) + + activation = await self.subscription_service.activate_subscription( + session, + payment.user_id, + payment_months, + float(payment.amount), + payment.payment_id, + provider="platega", + ) + + 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("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True) + return web.Response(status=500, text="processing_error") + + 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("Platega 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=currency, + months=payment_months, + payment_provider="platega", + username=db_user.username if db_user else None, + ) + except Exception as exc: + logging.error("Platega webhook: failed to notify admins: %s", exc) + + return web.Response(text="ok") + + if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + transaction_id, + "canceled", + ) + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc) + return web.Response(status=500, text="processing_error") + + 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.Response(text="ok_canceled") + + logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id) + return web.Response(status=202, text="status_ignored") + + +async def platega_webhook_route(request: web.Request) -> web.Response: + service: PlategaService = request.app["platega_service"] + return await service.webhook_route(request) diff --git a/config/settings.py b/config/settings.py index c0b0298..1c81e88 100644 --- a/config/settings.py +++ b/config/settings.py @@ -53,6 +53,15 @@ class Settings(BaseSettings): CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat") CRYPTOPAY_ASSET: str = Field(default="RUB") CRYPTOPAY_ENABLED: bool = Field(default=True) + PLATEGA_ENABLED: bool = Field(default=False) + PLATEGA_BASE_URL: str = Field(default="https://app.platega.io") + PLATEGA_MERCHANT_ID: Optional[str] = None + PLATEGA_SECRET: Optional[str] = None + PLATEGA_PAYMENT_METHOD: int = Field( + default=2, description="Platega payment method ID (e.g., 2 for SBP QR)" + ) + PLATEGA_RETURN_URL: Optional[str] = Field(default=None) + PLATEGA_FAILED_URL: Optional[str] = Field(default=None) FREEKASSA_ENABLED: bool = Field(default=False) FREEKASSA_MERCHANT_ID: Optional[str] = None @@ -294,6 +303,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.freekassa_webhook_path}" return None + @computed_field + @property + def platega_webhook_path(self) -> str: + return "/webhook/platega" + + @computed_field + @property + def platega_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.platega_webhook_path}" + return None + # Computed YooKassa receipt fields based on recurring toggle @computed_field @property @@ -390,7 +412,12 @@ class Settings(BaseSettings): return None return v - @field_validator('REQUIRED_CHANNEL_LINK', mode='before') + @field_validator( + 'REQUIRED_CHANNEL_LINK', + 'PLATEGA_RETURN_URL', + 'PLATEGA_FAILED_URL', + mode='before', + ) @classmethod def sanitize_optional_link(cls, v): if isinstance(v, str) and not v.strip(): @@ -457,6 +484,15 @@ def get_settings() -> Settings: "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." ) + if _settings_instance.PLATEGA_ENABLED: + if ( + not _settings_instance.PLATEGA_MERCHANT_ID + or not _settings_instance.PLATEGA_SECRET + ): + logging.warning( + "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." + ) + except ValidationError as e: logging.critical( f"Pydantic validation error while loading settings: {e}") diff --git a/locales/en.json b/locales/en.json index 553f069..77c7749 100644 --- a/locales/en.json +++ b/locales/en.json @@ -37,6 +37,7 @@ "back_to_autopay_method_choice_button": "⬅️ Back", "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)", "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 b3d4714..eb9d86a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -37,6 +37,7 @@ "back_to_autopay_method_choice_button": "⬅️ Назад", "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", "pay_with_sbp_button": "📱 СБП", + "pay_with_platega_button": "💳 Platega (СБП/карты)", "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute",