added platega.io
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`). |
|
||||
</details>
|
||||
| `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`. |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
@@ -149,6 +155,7 @@
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -207,6 +207,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
"stars_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"platega_service",
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
|
||||
@@ -240,7 +240,8 @@ class NotificationService:
|
||||
"freekassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
"tribute": "💎",
|
||||
"platega": "💳",
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
message = _(
|
||||
|
||||
@@ -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)
|
||||
+37
-1
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user